feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { join, resolve } from 'path'
import { homedir } from 'os'
/**
* Web UI environment variables.
*
* Server/listen:
* - PORT: Web UI listen port. Default: 8648.
* - BIND_HOST: Web UI bind host. Default: 0.0.0.0.
* - CORS_ORIGINS: Koa CORS origin setting. Default: *.
*
* Web UI storage:
* - HERMES_WEB_UI_HOME: Web UI data home for auth token, credentials, logs, DB, and default uploads.
* - HERMES_WEBUI_STATE_DIR: Compatibility alias for HERMES_WEB_UI_HOME.
* Default: join(homedir(), '.hermes-web-ui').
* - UPLOAD_DIR: Upload directory override. Default: join(HERMES_WEB_UI_HOME, 'upload').
* - dataDir: Development-only internal Web UI runtime data directory.
*
* Auth:
* - AUTH_TOKEN: Explicit bearer token. If unset, Web UI stores an auto-generated token under HERMES_WEB_UI_HOME.
*
* Runtime behavior:
* - PROFILE: Initial Hermes profile name. Default: default.
* - GATEWAY_HOST: Default gateway host written into profile config. Default: 127.0.0.1.
* - HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN: Whether Web UI shutdown also stops gateways.
* - WORKSPACE_BASE: Base directory for workspace browsing. Default: /opt/data/workspace.
*
* Limits/logging:
* - MAX_DOWNLOAD_SIZE: Max file download size. Default: 200MB.
* - MAX_EDIT_SIZE: Max editable file size. Default: 10MB.
* - LOG_LEVEL: Server log level. Default: info.
* - BRIDGE_LOG_LEVEL: Bridge log level. Default: LOG_LEVEL or info.
*/
export function getListenHost(env: Record<string, string | undefined> = process.env): string {
const host = env.BIND_HOST?.trim()
return host || '0.0.0.0'
}
export function getWebUiHome(env: Record<string, string | undefined> = process.env): string {
const appHome = env.HERMES_WEB_UI_HOME?.trim() || env.HERMES_WEBUI_STATE_DIR?.trim()
return appHome ? resolve(appHome) : join(homedir(), '.hermes-web-ui')
}
export function shouldCreateWebUiDataDir(env: Record<string, string | undefined> = process.env): boolean {
return env.NODE_ENV !== 'production'
}
const appHome = getWebUiHome()
export const config = {
port: parseInt(process.env.PORT || '8648', 10),
// Default to IPv4 for stable WSL/Windows browser access. Use BIND_HOST=:: explicitly for IPv6.
host: getListenHost(),
appHome,
uploadDir: process.env.UPLOAD_DIR || join(appHome, 'upload'),
dataDir: resolve(__dirname, '..', 'data'),
corsOrigins: process.env.CORS_ORIGINS || '*',
}
+423
View File
@@ -0,0 +1,423 @@
import type { Context } from 'koa'
import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter'
import {
DEFAULT_PASSWORD,
DEFAULT_USERNAME,
bootstrapDefaultSuperAdmin,
countActiveSuperAdmins,
countUsers,
createUser,
deleteUser,
findUserById,
findUserByUsername,
listUsers,
updateUser,
updateUsername,
updateUserPassword,
verifyPassword,
type UserRole,
type UserStatus,
} from '../db/hermes/users-store'
import { issueUserJwt } from '../middleware/user-auth'
import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile'
/**
* GET /api/auth/status
* Check if username/password login is configured (public).
*/
export async function authStatus(ctx: Context) {
ctx.body = {
hasPasswordLogin: true,
hasUsers: countUsers() > 0,
}
}
/**
* GET /api/auth/me
* Return the authenticated account.
*/
export async function currentUser(ctx: Context) {
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
ctx.body = {
user: {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
created_at: user.created_at,
updated_at: user.updated_at,
last_login_at: user.last_login_at,
requiresCredentialChange: process.env.HERMES_DESKTOP === 'true'
? false
: user.username === DEFAULT_USERNAME && verifyPassword(DEFAULT_PASSWORD, user.password_hash),
},
}
}
/**
* POST /api/auth/login
* Authenticate with username/password (public).
* Returns a user-scoped JWT on success.
*/
export async function login(ctx: Context) {
const { username, password } = ctx.request.body as { username?: string; password?: string }
if (!username || !password) {
ctx.status = 400
ctx.body = { error: 'Username and password are required' }
return
}
const ip = extractIp(ctx)
const result = checkPassword(ip)
if (!result.allowed) {
ctx.status = result.status
ctx.body = { error: 'Too many login attempts, please try again later' }
return
}
const existingUserCount = countUsers()
const user = existingUserCount === 0
? bootstrapDefaultSuperAdmin(username, password)
: findUserByUsername(username)
if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) {
recordPasswordFailure(ip)
ctx.status = 401
ctx.body = { error: 'Invalid username or password' }
return
}
let token: string
try {
token = await issueUserJwt(user)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err?.message || 'Failed to issue login token' }
return
}
recordPasswordSuccess(ip)
ctx.body = { token }
}
/**
* POST /api/auth/setup
* Set up username/password (protected).
*/
export async function setupPassword(ctx: Context) {
ctx.status = 400
ctx.body = { error: 'Password login is managed by user accounts' }
}
/**
* POST /api/auth/change-password
* Change password (protected).
*/
export async function changePassword(ctx: Context) {
const { currentPassword, newPassword } = ctx.request.body as { currentPassword?: string; newPassword?: string }
if (!currentPassword || !newPassword) {
ctx.status = 400
ctx.body = { error: 'Current password and new password are required' }
return
}
if (newPassword.length < 6) {
ctx.status = 400
ctx.body = { error: 'New password must be at least 6 characters' }
return
}
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
updateUserPassword(user.id, newPassword)
ctx.body = { success: true }
}
/**
* POST /api/auth/change-username
* Change username (protected).
*/
export async function changeUsername(ctx: Context) {
const { currentPassword, newUsername } = ctx.request.body as { currentPassword?: string; newUsername?: string }
if (!currentPassword || !newUsername) {
ctx.status = 400
ctx.body = { error: 'Current password and new username are required' }
return
}
if (newUsername.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
const userId = ctx.state.user?.id
const user = userId ? findUserById(userId) : null
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
ctx.status = 400
ctx.body = { error: 'Current password is incorrect' }
return
}
const existing = findUserByUsername(newUsername)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
updateUsername(user.id, newUsername)
ctx.body = { success: true }
}
/**
* DELETE /api/auth/password
* Remove username/password login (protected).
*/
export async function removePassword(ctx: Context) {
ctx.status = 400
ctx.body = { error: 'Password login cannot be removed for user accounts' }
}
function normalizeRole(value: unknown): UserRole | null {
return value === 'super_admin' || value === 'admin' ? value : null
}
function normalizeStatus(value: unknown): UserStatus | null {
return value === 'active' || value === 'disabled' ? value : null
}
function normalizeProfiles(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
}
function validateProfiles(profiles: string[]): string | null {
const available = new Set(listProfileNamesFromDisk())
const missing = profiles.find(profile => !available.has(profile))
return missing || null
}
/**
* GET /api/auth/users
* Super admin user management list.
*/
export async function listManagedUsers(ctx: Context) {
ctx.body = {
users: listUsers(),
profiles: listProfileNamesFromDisk(),
}
}
/**
* POST /api/auth/users
* Create a user account. Super admin only.
*/
export async function createManagedUser(ctx: Context) {
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = String(body.username || '').trim()
const password = String(body.password || '')
const role = normalizeRole(body.role || 'admin')
const status = normalizeStatus(body.status || 'active')
const profiles = normalizeProfiles(body.profiles)
if (username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (!role || !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (findUserByUsername(username)) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
const user = createUser({
username,
password,
role,
status,
profiles: role === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.status = 201
ctx.body = { user, users: listUsers() }
}
/**
* PUT /api/auth/users/:id
* Update user account metadata, password, and profile bindings.
*/
export async function updateManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
const body = ctx.request.body as {
username?: string
password?: string
role?: unknown
status?: unknown
profiles?: unknown
defaultProfile?: string | null
}
const username = body.username == null ? undefined : String(body.username).trim()
const password = body.password == null ? undefined : String(body.password)
const role = body.role == null ? undefined : normalizeRole(body.role)
const status = body.status == null ? undefined : normalizeStatus(body.status)
const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles)
if (username !== undefined && username.length < 2) {
ctx.status = 400
ctx.body = { error: 'Username must be at least 2 characters' }
return
}
if (password !== undefined && password.length > 0 && password.length < 6) {
ctx.status = 400
ctx.body = { error: 'Password must be at least 6 characters' }
return
}
if (body.role != null && !role || body.status != null && !status) {
ctx.status = 400
ctx.body = { error: 'Invalid role or status' }
return
}
if (username && username !== user.username) {
const existing = findUserByUsername(username)
if (existing && existing.id !== user.id) {
ctx.status = 409
ctx.body = { error: 'Username already exists' }
return
}
}
const nextRole = role || user.role
const nextStatus = status || user.status
const currentUserId = ctx.state.user?.id
if (user.id === currentUserId && nextStatus !== 'active') {
ctx.status = 400
ctx.body = { error: 'You cannot disable your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
if (profiles) {
const missingProfile = validateProfiles(profiles)
if (missingProfile) {
ctx.status = 400
ctx.body = { error: `Profile "${missingProfile}" does not exist` }
return
}
}
updateUser({
userId: user.id,
username,
password: password || undefined,
role: role || undefined,
status: status || undefined,
profiles: nextRole === 'super_admin' ? [] : profiles,
defaultProfile: body.defaultProfile,
})
ctx.body = { user: findUserById(user.id), users: listUsers() }
}
/**
* DELETE /api/auth/users/:id
* Delete a user account. Super admin only.
*/
export async function deleteManagedUser(ctx: Context) {
const id = Number(ctx.params.id)
const user = Number.isInteger(id) ? findUserById(id) : null
if (!user) {
ctx.status = 404
ctx.body = { error: 'User not found' }
return
}
if (ctx.state.user?.id === user.id) {
ctx.status = 400
ctx.body = { error: 'You cannot delete your own account' }
return
}
if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) {
ctx.status = 400
ctx.body = { error: 'At least one active super administrator is required' }
return
}
deleteUser(user.id)
ctx.body = { success: true, users: listUsers() }
}
/**
* GET /api/auth/locked-ips
* List all currently locked IPs (protected).
*/
export async function listLockedIps(ctx: Context) {
const locks = getLockedIps()
ctx.body = { locks }
}
/**
* DELETE /api/auth/locked-ips?ip=xxx
* Unlock a specific IP. No ip param = unlock all.
*/
export async function unlockIpHandler(ctx: Context) {
const ip = ctx.query.ip as string
if (ip) {
const found = unlockIp(ip)
if (!found) {
ctx.status = 404
ctx.body = { error: 'IP not locked' }
return
}
ctx.body = { success: true }
return
}
// No IP specified — unlock all
const count = unlockAll()
ctx.body = { success: true, count }
}
@@ -0,0 +1,119 @@
import type { Context } from 'koa'
import {
deleteCodingAgent,
getCodingAgentsStatus,
installCodingAgent,
openCodingAgentNativeTerminal,
prepareCodingAgentLaunch,
readCodingAgentConfigFile,
writeCodingAgentConfigFile,
type CodingAgentConfigScope,
} from '../services/coding-agents'
function configScope(ctx: Context): CodingAgentConfigScope {
const body = ctx.request.body as { profile?: unknown; provider?: unknown } | undefined
return {
profile: ctx.state.profile?.name || (typeof ctx.query.profile === 'string' ? ctx.query.profile : '') || (typeof body?.profile === 'string' ? body.profile : ''),
provider: (typeof ctx.query.provider === 'string' ? ctx.query.provider : '') || (typeof body?.provider === 'string' ? body.provider : ''),
}
}
export async function status(ctx: Context) {
try {
ctx.body = await getCodingAgentsStatus()
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message || 'Failed to inspect coding agents' }
}
}
export async function install(ctx: Context) {
try {
const result = await installCodingAgent(ctx.params.id)
ctx.body = result
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to install coding agent' }
}
}
export async function remove(ctx: Context) {
try {
const result = await deleteCodingAgent(ctx.params.id)
ctx.body = result
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to delete coding agent' }
}
}
export async function readConfigFile(ctx: Context) {
try {
ctx.body = await readCodingAgentConfigFile(ctx.params.id, ctx.params.key, configScope(ctx))
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to read coding agent config file' }
}
}
export async function writeConfigFile(ctx: Context) {
try {
const { content } = ctx.request.body as { content?: string }
ctx.body = await writeCodingAgentConfigFile(ctx.params.id, ctx.params.key, content || '', configScope(ctx))
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to write coding agent config file' }
}
}
export async function prepareLaunch(ctx: Context) {
try {
const body = ctx.request.body as {
mode?: any
profile?: string
provider?: string
model?: string
baseUrl?: string
apiKey?: string
apiMode?: any
}
ctx.body = await prepareCodingAgentLaunch(ctx.params.id, {
mode: body.mode,
profile: ctx.state.profile?.name || body.profile,
provider: body.provider,
model: body.model,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
apiMode: body.apiMode,
})
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to prepare coding agent launch' }
}
}
export async function nativeLaunch(ctx: Context) {
try {
const body = ctx.request.body as {
mode?: any
profile?: string
provider?: string
model?: string
baseUrl?: string
apiKey?: string
apiMode?: any
}
ctx.body = await openCodingAgentNativeTerminal(ctx.params.id, {
mode: body.mode,
profile: ctx.state.profile?.name || body.profile,
provider: body.provider,
model: body.model,
baseUrl: body.baseUrl,
apiKey: body.apiKey,
apiMode: body.apiMode,
})
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || 'Failed to launch native terminal' }
}
}
+100
View File
@@ -0,0 +1,100 @@
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import * as hermesCli from '../services/hermes/hermes-cli'
declare const __APP_VERSION__: string
type PackageInfo = {
name: string
version: string
}
function readPackageInfo(): PackageInfo | null {
const candidatePaths = [
// ts-node dev: packages/server/src/controllers -> repo root
resolve(__dirname, '../../../../package.json'),
// bundled server: dist/server -> repo root/package root
resolve(__dirname, '../../package.json'),
// fallback for dev/test processes started at the repo root
resolve(process.cwd(), 'package.json'),
]
for (const packagePath of candidatePaths) {
if (!existsSync(packagePath)) continue
try {
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'))
if (pkg?.name && pkg?.version) {
return {
name: String(pkg.name),
version: String(pkg.version),
}
}
} catch {
// Try the next candidate path.
}
}
return null
}
const PACKAGE_INFO = readPackageInfo()
const LOCAL_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: PACKAGE_INFO?.version || ''
let cachedLatestVersion = ''
/**
* Whether the periodic npm-registry version check is disabled.
*
* Useful when hermes-web-ui is bundled inside a packaged distribution
* (e.g. a desktop app) where the user can't `npm install -g hermes-web-ui@latest`
* to upgrade — the "update available" prompt would be misleading and
* the periodic outbound HTTP request to the npm registry is unnecessary.
*
* Set HERMES_WEB_UI_DISABLE_UPDATE_CHECK=true (or 1, on, yes) to disable.
*/
function isUpdateCheckDisabled(): boolean {
const raw = (process.env.HERMES_WEB_UI_DISABLE_UPDATE_CHECK || '').trim().toLowerCase()
return raw === 'true' || raw === '1' || raw === 'on' || raw === 'yes'
}
export async function checkLatestVersion(): Promise<void> {
if (isUpdateCheckDisabled()) return
try {
const packageName = PACKAGE_INFO?.name || 'hermes-web-ui'
const registryName = encodeURIComponent(packageName)
const res = await fetch(`https://registry.npmjs.org/${registryName}/latest`, { signal: AbortSignal.timeout(10000) })
if (res.ok) {
const data = await res.json() as { version: string }
cachedLatestVersion = data.version
if (LOCAL_VERSION && cachedLatestVersion !== LOCAL_VERSION) {
console.log(`Update available: ${LOCAL_VERSION}${cachedLatestVersion}`)
}
}
} catch { /* ignore */ }
}
export function startVersionCheck(): void {
if (isUpdateCheckDisabled()) return
setTimeout(checkLatestVersion, 5000)
setInterval(checkLatestVersion, 30 * 60 * 1000)
}
export async function healthCheck(ctx: any) {
const raw = await hermesCli.getVersion()
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
ctx.body = {
status: 'ok',
platform: 'hermes-agent',
version: hermesVersion,
gateway: 'running',
webui_version: LOCAL_VERSION,
webui_latest: isUpdateCheckDisabled() ? '' : cachedLatestVersion,
webui_update_available: isUpdateCheckDisabled()
? false
: Boolean(LOCAL_VERSION && cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION),
node_version: process.versions.node,
}
}
@@ -0,0 +1,243 @@
import { randomUUID } from 'crypto'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { logger } from '../../services/logger'
// --- OAuth Constants ---
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode'
const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token'
const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'
const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex'
const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback'
const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device'
const CODEX_HOME = join(homedir(), '.codex')
const POLL_MAX_DURATION = 15 * 60 * 1000
const POLL_DEFAULT_INTERVAL = 5000
// --- Session Store ---
interface CodexSession {
id: string; userCode: string; deviceAuthId: string
profile: string
status: 'pending' | 'approved' | 'expired' | 'error'
error?: string; accessToken?: string; refreshToken?: string; createdAt: number
}
const sessions = new Map<string, CodexSession>()
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => { if (now - session.createdAt > POLL_MAX_DURATION + 60000) { sessions.delete(id) } })
}
// --- Auth file helpers ---
interface AuthJson { version?: number; active_provider?: string; providers?: Record<string, any>; credential_pool?: Record<string, any[]>; updated_at?: string }
interface CodexCredentialRef {
accessToken: string
refreshToken?: string
lastRefresh?: string
provider?: any
poolEntry?: any
}
function loadAuthJson(authPath: string): AuthJson {
try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } }
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = dirname(authPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function saveCodexCliTokens(accessToken: string, refreshToken: string): void {
const codexHome = process.env.CODEX_HOME || CODEX_HOME
const codexAuthPath = join(codexHome, 'auth.json')
const dir = dirname(codexAuthPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(codexAuthPath, JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 })
}
function requestedProfile(ctx: any): string {
const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : ''
const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : ''
const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : ''
return ctx.state?.profile?.name ||
headerProfile.trim() ||
queryProfile.trim() ||
bodyProfile.trim() ||
getActiveProfileName() ||
'default'
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
function decodeJwtExp(token: string): number | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
const claims = JSON.parse(payload)
return typeof claims.exp === 'number' ? claims.exp : null
} catch { return null }
}
function getCodexCredential(auth: AuthJson): CodexCredentialRef | null {
const provider = auth.providers?.['openai-codex']
const providerTokens = provider?.tokens
const providerAccessToken = providerTokens?.access_token || provider?.access_token
const pool = auth.credential_pool?.['openai-codex']
const poolEntry = Array.isArray(pool) ? pool.find(entry => entry?.access_token) : undefined
if (providerAccessToken) {
return {
accessToken: providerAccessToken,
refreshToken: providerTokens?.refresh_token || provider?.refresh_token,
lastRefresh: provider.last_refresh,
provider,
poolEntry,
}
}
if (poolEntry?.access_token) {
return {
accessToken: poolEntry.access_token,
refreshToken: poolEntry.refresh_token,
lastRefresh: poolEntry.last_refresh,
poolEntry,
}
}
return null
}
// --- Background login worker ---
export function saveCodexOAuthTokensForProfile(profile: string, accessToken: string, refreshToken: string): void {
const authPath = authPathForProfile(profile)
const auth = loadAuthJson(authPath)
if (!auth.providers) auth.providers = {}
auth.providers['openai-codex'] = { tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString(), auth_mode: 'chatgpt' }
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['openai-codex'] = [{ id: `openai-codex-${Date.now()}`, label: 'OpenAI Codex', base_url: CODEX_DEFAULT_BASE_URL, access_token: accessToken, last_status: null }]
saveAuthJson(authPath, auth)
saveCodexCliTokens(accessToken, refreshToken)
}
async function codexLoginWorker(session: CodexSession): Promise<void> {
const startTime = Date.now()
const interval = POLL_DEFAULT_INTERVAL
while (Date.now() - startTime < POLL_MAX_DURATION) {
await new Promise(resolve => setTimeout(resolve, interval))
if (session.status !== 'pending') return
try {
const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_auth_id: session.deviceAuthId, user_code: session.userCode }),
signal: AbortSignal.timeout(10000),
})
if (pollRes.status === 200) {
const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string }
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code: pollData.authorization_code, redirect_uri: CODEX_REDIRECT_URI, client_id: CODEX_CLIENT_ID, code_verifier: pollData.code_verifier }).toString(),
signal: AbortSignal.timeout(15000),
})
if (!tokenRes.ok) { const errText = await tokenRes.text(); logger.error('Token exchange failed: %d %s', tokenRes.status, errText); session.status = 'error'; session.error = `Token exchange failed: ${tokenRes.status}`; return }
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string }
const refreshToken = tokenData.refresh_token || ''
session.accessToken = tokenData.access_token; session.refreshToken = refreshToken; session.status = 'approved'
saveCodexOAuthTokensForProfile(session.profile, tokenData.access_token, refreshToken)
logger.info('Login successful')
return
}
if (pollRes.status === 403 || pollRes.status === 404) { continue }
logger.error('Poll failed: %d', pollRes.status); session.status = 'error'; session.error = `Poll failed: ${pollRes.status}`; return
} catch (err: any) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') { continue }
logger.error(err, 'Poll error'); session.status = 'error'; session.error = err.message; return
}
}
session.status = 'expired'
}
// --- Controller functions ---
export async function start(ctx: any) {
try {
cleanupExpiredSessions()
const res = await fetch(CODEX_DEVICE_AUTH_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'node-fetch' },
body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
let errorBody: any = null; try { errorBody = await res.json() } catch { }
logger.error('Device code request failed: %d %s', res.status, errorBody)
let errorMessage = `Device code request failed: ${res.status}`
if (errorBody?.error?.code === 'unsupported_country_region_territory') { errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' }
ctx.status = 502; ctx.body = { error: errorMessage, code: errorBody?.error?.code }; return
}
const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string }
const sessionId = randomUUID()
const session: CodexSession = { id: sessionId, userCode: data.user_code, deviceAuthId: data.device_auth_id, profile: requestedProfile(ctx), status: 'pending', createdAt: Date.now() }
sessions.set(sessionId, session)
codexLoginWorker(session).catch(err => { logger.error(err, 'Worker error'); session.status = 'error'; session.error = err.message })
ctx.body = { session_id: sessionId, user_code: data.user_code, verification_url: CODEX_VERIFICATION_URL, expires_in: 900 }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function poll(ctx: any) {
const session = sessions.get(ctx.params.sessionId)
if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return }
ctx.body = { status: session.status, error: session.error || null }
}
export async function status(ctx: any) {
try {
const authPath = authPathForProfile(requestedProfile(ctx))
const auth = loadAuthJson(authPath)
const credential = getCodexCredential(auth)
if (!credential) { ctx.body = { authenticated: false }; return }
const exp = decodeJwtExp(credential.accessToken)
if (exp && exp <= Date.now() / 1000 + 120) {
if (credential.refreshToken) {
try {
const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: credential.refreshToken, client_id: CODEX_CLIENT_ID }).toString(),
signal: AbortSignal.timeout(15000),
})
if (refreshRes.ok) {
const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string }
const lastRefresh = new Date().toISOString()
if (credential.provider?.tokens) {
credential.provider.tokens.access_token = newTokens.access_token
if (newTokens.refresh_token) { credential.provider.tokens.refresh_token = newTokens.refresh_token }
credential.provider.last_refresh = lastRefresh
} else if (credential.provider) {
credential.provider.access_token = newTokens.access_token
if (newTokens.refresh_token) { credential.provider.refresh_token = newTokens.refresh_token }
credential.provider.last_refresh = lastRefresh
}
if (credential.poolEntry) {
credential.poolEntry.access_token = newTokens.access_token
if (newTokens.refresh_token) { credential.poolEntry.refresh_token = newTokens.refresh_token }
credential.poolEntry.last_refresh = lastRefresh
}
saveAuthJson(authPath, auth)
saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || credential.refreshToken)
ctx.body = { authenticated: true, last_refresh: lastRefresh }; return
}
} catch { }
}
ctx.body = { authenticated: false }; return
}
ctx.body = { authenticated: true, last_refresh: credential.lastRefresh }
} catch { ctx.body = { authenticated: false } }
}
@@ -0,0 +1,236 @@
import { readFile } from 'fs/promises'
import { join } from 'path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart'
import { saveEnvValueForProfile } from '../../services/config-helpers'
import { logger } from '../../services/logger'
import { safeFileStore } from '../../services/safe-file-store'
const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
'weixin', 'wecom', 'feishu', 'dingtalk', 'qqbot',
'approvals',
])
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
const configPath = (profile: string) => join(getProfileDir(profile), 'config.yaml')
const envPath = (profile: string) => join(getProfileDir(profile), '.env')
const envPlatformMap: Record<string, [string, string]> = {
TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
DISCORD_BOT_TOKEN: ['discord', 'token'],
SLACK_BOT_TOKEN: ['slack', 'token'],
MATRIX_ACCESS_TOKEN: ['matrix', 'token'],
MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'],
FEISHU_APP_ID: ['feishu', 'extra.app_id'],
FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'],
DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
DINGTALK_CARD_TEMPLATE_ID: ['dingtalk', 'extra.card_template_id'],
DINGTALK_ALLOWED_USERS: ['dingtalk', 'allowed_users'],
DINGTALK_ALLOW_ALL_USERS: ['dingtalk', 'allow_all_users'],
QQ_APP_ID: ['qqbot', 'extra.app_id'],
QQ_CLIENT_SECRET: ['qqbot', 'extra.client_secret'],
QQ_ALLOWED_USERS: ['qqbot', 'allowed_users'],
QQ_ALLOW_ALL_USERS: ['qqbot', 'allow_all_users'],
WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
WECOM_SECRET: ['wecom', 'extra.secret'],
WEIXIN_TOKEN: ['weixin', 'token'],
WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'],
WEIXIN_BASE_URL: ['weixin', 'extra.base_url'],
WHATSAPP_ENABLED: ['whatsapp', 'enabled'],
}
const platformEnvMap: Record<string, Record<string, string>> = {}
for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
platformEnvMap[platform][cfgPath] = envVar
}
function parseEnv(raw: string): Record<string, string> {
const env: Record<string, string> = {}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim()
if (val) env[key] = val
}
return env
}
function setNested(obj: Record<string, any>, path: string, value: any) {
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]] }
cur[parts[parts.length - 1]] = value
}
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) &&
target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
target[key] = deepMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
async function readEnvPlatforms(profile: string): Promise<Record<string, any>> {
try {
const raw = await readFile(envPath(profile), 'utf-8')
const env = parseEnv(raw)
const platforms: Record<string, any> = {}
for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
const val = env[envKey]
if (val === undefined || val === '') continue
if (!platforms[platform]) platforms[platform] = {}
let finalVal: any = val
if (cfgPath === 'enabled' || cfgPath === 'allow_all_users') finalVal = val === 'true'
setNested(platforms[platform], cfgPath, finalVal)
}
return platforms
} catch { return {} }
}
async function readConfig(profile: string): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPath(profile))
}
export async function getConfig(ctx: any) {
try {
const profile = requestedProfile(ctx)
const config = await readConfig(profile)
const envPlatforms = await readEnvPlatforms(profile)
if (Object.keys(envPlatforms).length > 0) {
const existing = config.platforms || {}
for (const [platform, vals] of Object.entries(envPlatforms)) {
existing[platform] = deepMerge(existing[platform] || {}, vals as Record<string, any>)
}
config.platforms = existing
}
const { section, sections } = ctx.query
if (section) {
ctx.body = { [section as string]: config[section as string] || {} }
} else if (sections) {
const keys = (sections as string).split(',')
const result: Record<string, any> = {}
for (const key of keys) { result[key.trim()] = config[key.trim()] || {} }
ctx.body = result
} else {
ctx.body = config
}
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function updateConfig(ctx: any) {
const { section, values, restart } = ctx.request.body as { section: string; values: Record<string, any>; restart?: boolean }
if (!section || !values) {
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
}
try {
const profile = requestedProfile(ctx)
await safeFileStore.updateYaml(configPath(profile), (config) => {
config[section] = deepMerge(config[section] || {}, values)
return config
}, {
backup: true,
dumpOptions: {
forceQuotes: true,
},
})
// Platform adapters run through Hermes gateway; restart it so channel
// config changes (Feishu/Weixin/etc.) are applied.
if (restart !== false && PLATFORM_SECTIONS.has(section)) {
try {
const restartResult = await restartGatewayForProfile(profile)
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%j', section, profile, restartResult)
} catch (err) {
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function updateCredentials(ctx: any) {
const { platform, values } = ctx.request.body as { platform: string; values: Record<string, any> }
if (!platform || !values) {
ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return
}
try {
const profile = requestedProfile(ctx)
const envMap = platformEnvMap[platform]
if (!envMap) {
ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return
}
const flatValues: Record<string, any> = {}
for (const [key, val] of Object.entries(values)) {
if (key === 'extra' && val && typeof val === 'object') {
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) { flatValues[`extra.${subKey}`] = subVal }
} else { flatValues[key] = val }
}
await safeFileStore.updateYaml(configPath(profile), async (config) => {
for (const [cfgPath, val] of Object.entries(flatValues)) {
const envVar = envMap[cfgPath]
if (!envVar) continue
if (val === undefined || val === null || val === '') {
await saveEnvValueForProfile(profile, envVar, '')
const parts = cfgPath.split('.')
let obj: any = config.platforms?.[platform]
if (obj) {
if (parts.length === 1) { delete obj[parts[0]] }
else {
let cur = obj
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] }
delete cur[parts[parts.length - 1]]
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
}
if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] }
}
} else {
await saveEnvValueForProfile(profile, envVar, String(val))
}
}
return config
}, {
backup: true,
dumpOptions: {
forceQuotes: true,
},
})
// Platform adapters run through Hermes gateway; restart it so channel
// credentials are applied.
try {
const restartResult = await restartGatewayForProfile(profile)
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%j', platform, profile, restartResult)
} catch (err) {
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,238 @@
import { randomUUID } from 'crypto'
import { startDeviceFlow, pollDeviceFlow } from '../../services/hermes/copilot-device-flow'
import { saveEnvValue, updateConfigYaml } from '../../services/config-helpers'
import {
invalidateAllCaches,
resolveCopilotOAuthTokenWithSource,
type CopilotTokenSource,
} from '../../services/hermes/copilot-models'
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { readAppConfig, writeAppConfig } from '../../services/app-config'
import { readFile } from 'fs/promises'
import { logger } from '../../services/logger'
const POLL_MAX_DURATION_MS = 15 * 60 * 1000 // 15 minutes hard ceiling
const SESSION_GC_GRACE_MS = 60 * 1000
interface CopilotLoginSession {
id: string
deviceCode: string
userCode: string
verificationUrl: string
expiresIn: number
interval: number
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error?: string
createdAt: number
}
const sessions = new Map<string, CopilotLoginSession>()
function cleanupSessions(): void {
const now = Date.now()
sessions.forEach((s, id) => {
if (now - s.createdAt > POLL_MAX_DURATION_MS + SESSION_GC_GRACE_MS) {
sessions.delete(id)
}
})
}
async function persistToken(token: string): Promise<void> {
// 与 disable 对称:只动 ~/.hermes/.envapps.json 是 VS Code 的文件不要碰。
// 同时把 enabled 置 true —— device flow 完成后用户已显式同意启用 Copilot。
// NOTE: 故意不写 process.env.COPILOT_GITHUB_TOKEN —— 否则该值会跨 profile 持续覆盖
// resolveCopilotOAuthTokenWithSource 的 .env 读取,导致切到别的 profile 仍解析到当前
// profile 的 token。invalidateAllCaches() + .env 文件本身已能保证下次解析读到新 token。
await saveEnvValue('COPILOT_GITHUB_TOKEN', token)
await writeAppConfig({ copilotEnabled: true })
invalidateAllCaches()
}
async function readEnvContent(): Promise<string> {
try { return await readFile(getActiveEnvPath(), 'utf-8') } catch { return '' }
}
async function loginWorker(session: CopilotLoginSession): Promise<void> {
const startTime = Date.now()
let interval = Math.max(1, session.interval) * 1000
while (Date.now() - startTime < POLL_MAX_DURATION_MS) {
await new Promise((resolve) => setTimeout(resolve, interval))
if (session.status !== 'pending') return
const result = await pollDeviceFlow(session.deviceCode)
if (result.kind === 'success') {
try {
await persistToken(result.access_token)
session.status = 'approved'
logger.info('Copilot OAuth login successful')
} catch (err: any) {
logger.error(err, 'Copilot OAuth: failed to persist token')
session.status = 'error'
session.error = err?.message ?? 'failed to persist token'
}
return
}
if (result.kind === 'pending') continue
if (result.kind === 'slow_down') {
interval += 5000
continue
}
if (result.kind === 'denied') {
session.status = 'denied'
return
}
if (result.kind === 'expired') {
session.status = 'expired'
return
}
logger.error('Copilot OAuth poll error: %s %s', result.error, result.description ?? '')
session.status = 'error'
session.error = result.description ?? result.error
return
}
session.status = 'expired'
}
export async function start(ctx: any): Promise<void> {
cleanupSessions()
try {
const data = await startDeviceFlow()
const sessionId = randomUUID()
const session: CopilotLoginSession = {
id: sessionId,
deviceCode: data.device_code,
userCode: data.user_code,
verificationUrl: data.verification_uri,
expiresIn: data.expires_in,
interval: data.interval,
status: 'pending',
createdAt: Date.now(),
}
sessions.set(sessionId, session)
loginWorker(session).catch((err) => {
logger.error(err, 'Copilot login worker error')
session.status = 'error'
session.error = err?.message ?? String(err)
})
ctx.body = {
session_id: sessionId,
user_code: data.user_code,
verification_url: data.verification_uri,
expires_in: data.expires_in,
interval: data.interval,
}
} catch (err: any) {
logger.error(err, 'Copilot OAuth start failed')
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
ctx.status = 504
ctx.body = { error: 'GitHub timeout' }
return
}
ctx.status = 502
ctx.body = { error: err?.message ?? 'GitHub OAuth start failed' }
}
}
export async function poll(ctx: any): Promise<void> {
const session = sessions.get(ctx.params.sessionId)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { status: session.status, error: session.error || null }
}
/**
* Reports current token resolution and whether Copilot is opt-in enabled.
* Frontend Add Provider flow uses this to decide whether to show the
* "token detected, click Add" confirmation or kick off device flow.
*
* Side effect: invalidates the model list cache so a subsequent listing
* picks up gh-cli logout / VS Code sign-out without server restart.
*/
export async function checkToken(ctx: any): Promise<void> {
invalidateAllCaches()
const env = await readEnvContent()
const { token, source } = await resolveCopilotOAuthTokenWithSource(env)
const cfg = await readAppConfig()
ctx.body = {
has_token: Boolean(token),
source: source as CopilotTokenSource,
enabled: cfg.copilotEnabled === true,
}
}
export async function enable(ctx: any): Promise<void> {
await writeAppConfig({ copilotEnabled: true })
invalidateAllCaches()
ctx.body = { ok: true }
}
/**
* "Soft delete" Copilot from the web-ui provider list.
* - Always: copilotEnabled = false (hides provider regardless of token source).
* - source='env' → also clear ~/.hermes/.env COPILOT_GITHUB_TOKEN
* (this token belongs to the hermes ecosystem).
* - source='gh-cli' → leave gh CLI alone (user's terminal sessions).
* - source='apps-json' → leave VS Code Copilot plugin alone.
* The user can re-add Copilot any time via "Add Provider".
*/
export async function disable(ctx: any): Promise<void> {
const env = await readEnvContent()
const { source } = await resolveCopilotOAuthTokenWithSource(env)
// 步骤 1:先清掉默认模型(最容易失败的一步:写 yaml 可能失败)。
// 不能 swallow —— 否则会出现 "list 已隐藏 copilot 但 default 仍是 copilot" 的中间态。
let clearedDefault = false
try {
clearedDefault = await updateConfigYaml((cfg) => {
const modelSection = cfg.model
if (typeof modelSection === 'object' && modelSection !== null) {
const provider = String(modelSection.provider || '').trim().toLowerCase()
if (provider === 'copilot') {
cfg.model = {}
return { data: cfg, result: true }
}
}
return { data: cfg, result: false, write: false }
}) || false
} catch (err: any) {
logger.error(err, 'Copilot disable failed: cannot clear default model')
ctx.status = 500
ctx.body = { error: `failed to clear default model: ${err?.message ?? 'unknown error'}` }
return
}
// 步骤 2:清 .env(仅当 source='env')。失败也不能让 enabled flag 偷偷置 false。
try {
if (source === 'env') {
await saveEnvValue('COPILOT_GITHUB_TOKEN', '')
delete process.env.COPILOT_GITHUB_TOKEN
}
} catch (err: any) {
logger.error(err, 'Copilot disable failed: cannot clear .env')
ctx.status = 500
ctx.body = { error: `failed to clear .env: ${err?.message ?? 'unknown error'}` }
return
}
// 步骤 3:最后翻 enabled flag。前两步成功才执行。
try {
await writeAppConfig({ copilotEnabled: false })
invalidateAllCaches()
} catch (err: any) {
logger.error(err, 'Copilot disable failed: cannot persist enabled flag')
ctx.status = 500
ctx.body = { error: `failed to persist enabled flag: ${err?.message ?? 'unknown error'}` }
return
}
ctx.body = { ok: true, cleared_env: source === 'env', cleared_default: clearedDefault }
}
@@ -0,0 +1,308 @@
import type { Context } from 'koa'
import { readdir, stat, readFile } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
const SYNTHETIC_RUN_FILE = '__scheduler_metadata__.md'
function requestedProfile(ctx: Context): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
function getCronOutputDir(profile: string): string {
const profileDir = getProfileDir(profile)
return join(profileDir, 'cron', 'output')
}
function getCronJobsFile(profile: string): string {
const profileDir = getProfileDir(profile)
return join(profileDir, 'cron', 'jobs.json')
}
export interface RunEntry {
jobId: string
fileName: string
runTime: string
size: number
hasOutput?: boolean
synthetic?: boolean
runCount?: number
status?: string | null
error?: string | null
}
export interface RunDetail {
jobId: string
fileName: string
runTime: string
content: string
}
interface CronJobMetadata {
id?: string
job_id?: string
name?: string
last_run_at?: string | null
last_status?: string | null
last_error?: string | null
run_count?: number | string | null
no_agent?: boolean
script?: string | null
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getJobId(job: CronJobMetadata): string | null {
return stringOrNull(job.job_id) || stringOrNull(job.id)
}
function isCronJobMetadata(value: unknown): value is CronJobMetadata {
return Boolean(value && typeof value === 'object')
}
function normaliseJobsPayload(payload: unknown): CronJobMetadata[] {
if (Array.isArray(payload)) return payload.filter(isCronJobMetadata)
if (payload && typeof payload === 'object') {
const maybeJobs = (payload as { jobs?: unknown }).jobs
if (Array.isArray(maybeJobs)) return maybeJobs.filter(isCronJobMetadata)
}
return []
}
async function readCronJobs(profile: string): Promise<CronJobMetadata[]> {
const jobsFile = getCronJobsFile(profile)
if (!existsSync(jobsFile)) return []
try {
const raw = await readFile(jobsFile, 'utf-8')
return normaliseJobsPayload(JSON.parse(raw))
} catch {
return []
}
}
function coerceRunCount(value: CronJobMetadata['run_count']): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function toDisplayTime(value: string): string {
const isoLike = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/)
if (isoLike) return `${isoLike[1]} ${isoLike[2]}:${isoLike[3]}:${isoLike[4]}`
const legacy = value.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})$/)
if (legacy) return `${legacy[1]} ${legacy[2].replace(/-/g, ':')}`
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) {
return new Date(parsed).toISOString().replace('T', ' ').slice(0, 19)
}
return value
}
function parseRunTimeFromFileName(fileName: string): string {
const base = fileName.endsWith('.md') ? fileName.slice(0, -3) : fileName
return toDisplayTime(base)
}
function syntheticRunEntry(job: CronJobMetadata): RunEntry | null {
const jobId = getJobId(job)
const lastRunAt = stringOrNull(job.last_run_at)
if (!jobId || !lastRunAt) return null
return {
jobId,
fileName: SYNTHETIC_RUN_FILE,
runTime: toDisplayTime(lastRunAt),
size: 0,
hasOutput: false,
synthetic: true,
runCount: coerceRunCount(job.run_count),
status: stringOrNull(job.last_status),
error: stringOrNull(job.last_error),
}
}
function hasRunForJobAtOrAfter(runs: RunEntry[], jobId: string, runTime: string): boolean {
return runs.some(run => run.jobId === jobId && run.runTime >= runTime)
}
function inlineCode(value: unknown): string {
const text = String(value)
let longestBacktickRun = 0
let currentBacktickRun = 0
for (const char of text) {
if (char === '`') {
currentBacktickRun += 1
if (currentBacktickRun > longestBacktickRun) longestBacktickRun = currentBacktickRun
} else {
currentBacktickRun = 0
}
}
const delimiter = '`'.repeat(longestBacktickRun + 1)
return `${delimiter} ${text} ${delimiter}`
}
function buildSyntheticContent(job: CronJobMetadata, runTime: string): string {
const explanation = job.no_agent || stringOrNull(job.script)
? 'This is expected for script-only/no-agent watchdog jobs when the script exits successfully with empty stdout: Hermes treats the run as silent, so there is nothing to deliver and no output file to display.'
: 'This can happen when a cron run updates scheduler metadata but does not produce a markdown output artifact to display.'
const lines = [
'# Scheduler run recorded',
'',
'Hermes recorded this cron job as having run, but no markdown output artifact was written for this job.',
'',
explanation,
'',
`- Job: ${inlineCode(job.name || getJobId(job) || 'unknown')}`,
`- Last run: ${inlineCode(runTime)}`,
]
const runCount = coerceRunCount(job.run_count)
const lastStatus = stringOrNull(job.last_status)
const lastError = stringOrNull(job.last_error)
const script = stringOrNull(job.script)
if (runCount !== undefined) lines.push(`- Recorded runs: ${inlineCode(runCount)}`)
if (lastStatus) lines.push(`- Last status: ${inlineCode(lastStatus)}`)
if (lastError) lines.push(`- Last error: ${inlineCode(lastError)}`)
if (script) lines.push(`- Script: ${inlineCode(script)}`)
if (job.no_agent) lines.push('- Mode: `no-agent/script-only`')
return `${lines.join('\n')}\n`
}
/** List all run output files, optionally filtered by job ID */
export async function listRuns(ctx: Context) {
const jobId = ctx.query.jobId as string | undefined
const profile = requestedProfile(ctx)
const cronOutput = getCronOutputDir(profile)
try {
const runs: RunEntry[] = []
if (existsSync(cronOutput)) {
const dirs = await readdir(cronOutput)
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
for (const dir of targetDirs) {
const dirPath = join(cronOutput, dir)
try {
const dirStat = await stat(dirPath)
if (!dirStat.isDirectory()) continue
const files = await readdir(dirPath)
// Sort by filename descending (newest first, since filenames are timestamps)
const sorted = files.sort().reverse()
for (const file of sorted) {
if (!file.endsWith('.md')) continue
const filePath = join(dirPath, file)
try {
const fileStat = await stat(filePath)
runs.push({
jobId: dir,
fileName: file,
runTime: parseRunTimeFromFileName(file),
size: fileStat.size,
hasOutput: true,
})
} catch { /* skip unreadable files */ }
}
} catch { /* skip unreadable dirs */ }
}
}
const jobs = await readCronJobs(profile)
const targetJobs = jobId ? jobs.filter(job => getJobId(job) === jobId) : jobs
for (const job of targetJobs) {
const id = getJobId(job)
if (!id) continue
const synthetic = syntheticRunEntry(job)
if (synthetic && !hasRunForJobAtOrAfter(runs, id, synthetic.runTime)) runs.push(synthetic)
}
// Sort all runs by runTime descending
runs.sort((a, b) => b.runTime.localeCompare(a.runTime))
ctx.body = { runs }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
/** Read a specific run output file */
export async function readRun(ctx: Context) {
const { jobId, fileName } = ctx.params
const profile = requestedProfile(ctx)
if (!jobId || !fileName) {
ctx.status = 400
ctx.body = { error: 'jobId and fileName are required' }
return
}
// Prevent path traversal
if (
jobId.includes('..')
|| fileName.includes('..')
|| jobId.includes('/')
|| fileName.includes('/')
|| jobId.includes('\\')
|| fileName.includes('\\')
) {
ctx.status = 400
ctx.body = { error: 'Invalid path' }
return
}
if (fileName === SYNTHETIC_RUN_FILE) {
const jobs = await readCronJobs(profile)
const job = jobs.find(candidate => getJobId(candidate) === jobId)
const synthetic = job ? syntheticRunEntry(job) : null
if (!job || !synthetic) {
ctx.status = 404
ctx.body = { error: 'Run output not found' }
return
}
ctx.body = {
jobId,
fileName,
runTime: synthetic.runTime,
content: buildSyntheticContent(job, synthetic.runTime),
} satisfies RunDetail
return
}
const cronOutput = getCronOutputDir(profile)
const filePath = join(cronOutput, jobId, fileName)
if (!existsSync(filePath)) {
ctx.status = 404
ctx.body = { error: 'Run output not found' }
return
}
try {
const content = await readFile(filePath, 'utf-8')
const runTime = parseRunTimeFromFileName(fileName)
ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,308 @@
import type { Context } from 'koa'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getHermesBin } from '../../services/hermes/hermes-path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { execHermesWithBin } from '../../services/hermes/hermes-process'
const TIMEOUT_MS = 60_000
type JobRecord = Record<string, any>
function resolveProfile(ctx: Context): string {
const requestedProfile = ctx.state?.profile?.name
return requestedProfile || getActiveProfileName()
}
function resolveProfileDir(profile: string): string {
return getProfileDir(profile || 'default')
}
function getJobsPath(profile: string): string {
return join(resolveProfileDir(profile), 'cron', 'jobs.json')
}
function normalizeJob(job: JobRecord): JobRecord {
const id = job.job_id || job.id
const skills = Array.isArray(job.skills)
? job.skills
: (job.skill ? [job.skill] : [])
return {
...job,
id,
job_id: id,
skills,
skill: job.skill ?? skills[0] ?? null,
model: job.model ?? null,
provider: job.provider ?? null,
base_url: job.base_url ?? null,
script: job.script ?? null,
schedule_display: job.schedule_display ?? job.schedule?.display ?? job.schedule?.expr ?? '',
repeat: job.repeat ?? { times: null, completed: 0 },
enabled: job.enabled ?? true,
state: job.state ?? ((job.enabled ?? true) ? 'scheduled' : 'paused'),
paused_at: job.paused_at ?? null,
paused_reason: job.paused_reason ?? null,
created_at: job.created_at ?? '',
next_run_at: job.next_run_at ?? null,
last_run_at: job.last_run_at ?? null,
last_status: job.last_status ?? null,
last_error: job.last_error ?? null,
deliver: job.deliver ?? 'local',
origin: job.origin ?? null,
last_delivery_error: job.last_delivery_error ?? null,
}
}
function readJobs(profile: string, includeDisabled = true): JobRecord[] {
const jobsPath = getJobsPath(profile)
if (!existsSync(jobsPath)) return []
const parsed = JSON.parse(readFileSync(jobsPath, 'utf-8'))
const rawJobs = Array.isArray(parsed) ? parsed : parsed?.jobs
const jobs = Array.isArray(rawJobs) ? rawJobs.map(normalizeJob) : []
if (includeDisabled) return jobs
return jobs.filter((job) => job.enabled !== false)
}
function findJob(profile: string, jobId: string): JobRecord | null {
return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null
}
function boolQuery(value: unknown, defaultValue: boolean): boolean {
if (value == null) return defaultValue
const text = String(value).toLowerCase()
return text === '1' || text === 'true' || text === 'yes'
}
function getBody(ctx: Context): Record<string, any> {
return (ctx.request.body && typeof ctx.request.body === 'object')
? ctx.request.body as Record<string, any>
: {}
}
function getRepeatValue(repeat: unknown): number | null {
if (repeat == null || repeat === '') return null
if (typeof repeat === 'number' && Number.isFinite(repeat)) return repeat
if (typeof repeat === 'object') {
const times = (repeat as any).times
if (typeof times === 'number' && Number.isFinite(times)) return times
if (typeof times === 'string' && times.trim()) {
const parsed = Number(times)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
const parsed = Number(repeat)
return Number.isFinite(parsed) ? parsed : null
}
function hasRepeatField(body: Record<string, any>): boolean {
return Object.prototype.hasOwnProperty.call(body, 'repeat')
}
function getSkills(body: Record<string, any>): string[] | null {
if (Array.isArray(body.skills)) {
return body.skills.map((skill) => String(skill || '').trim()).filter(Boolean)
}
if (typeof body.skill === 'string') {
const skill = body.skill.trim()
return skill ? [skill] : []
}
return null
}
async function runHermesCron(profile: string, args: string[]): Promise<void> {
const profileDir = resolveProfileDir(profile)
try {
await execHermesWithBin(getHermesBin(), args, {
cwd: process.cwd(),
env: { ...process.env, HERMES_HOME: profileDir },
timeout: TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true,
})
} catch (error: any) {
const stderr = String(error?.stderr || '').trim()
const stdout = String(error?.stdout || '').trim()
throw new Error(stderr || stdout || error?.message || 'Hermes cron command failed')
}
}
function sendJobNotFound(ctx: Context): void {
ctx.status = 404
ctx.body = { error: { message: 'Job not found' } }
}
function sendCommandError(ctx: Context, error: any): void {
ctx.status = 500
ctx.body = { error: { message: error?.message || 'Hermes cron command failed' } }
}
function findCreatedJob(beforeJobs: JobRecord[], afterJobs: JobRecord[]): JobRecord | null {
const beforeIds = new Set(beforeJobs.map((job) => job.job_id || job.id))
const created = afterJobs.find((job) => !beforeIds.has(job.job_id || job.id))
if (created) return created
return [...afterJobs].sort((a, b) => {
const aTime = Date.parse(a.created_at || '') || 0
const bTime = Date.parse(b.created_at || '') || 0
return bTime - aTime
})[0] ?? null
}
export async function list(ctx: Context) {
const profile = resolveProfile(ctx)
const includeDisabled = boolQuery(ctx.query.include_disabled, false)
ctx.body = { jobs: readJobs(profile, includeDisabled) }
}
export async function get(ctx: Context) {
const profile = resolveProfile(ctx)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
}
export async function create(ctx: Context) {
const profile = resolveProfile(ctx)
const body = getBody(ctx)
const schedule = String(body.schedule || body.schedule_display || '').trim()
const prompt = String(body.prompt || '').trim()
if (!schedule) {
ctx.status = 400
ctx.body = { error: { message: 'Schedule is required' } }
return
}
const beforeJobs = readJobs(profile, true)
const args = ['cron', 'create']
const name = String(body.name || '').trim()
if (name) args.push('--name', name)
if (body.deliver != null && String(body.deliver).trim()) args.push('--deliver', String(body.deliver).trim())
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
for (const skill of skills || []) args.push('--skill', skill)
if (body.script != null && String(body.script).trim()) args.push('--script', String(body.script).trim())
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
args.push(schedule)
if (prompt) args.push(prompt)
try {
await runHermesCron(profile, args)
const job = findCreatedJob(beforeJobs, readJobs(profile, true))
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function update(ctx: Context) {
const profile = resolveProfile(ctx)
const body = getBody(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
const args = ['cron', 'edit', ctx.params.id]
if (body.schedule != null || body.schedule_display != null) {
args.push('--schedule', String(body.schedule ?? body.schedule_display))
}
if (body.prompt != null) args.push('--prompt', String(body.prompt))
if (body.name != null) args.push('--name', String(body.name))
if (body.deliver != null) args.push('--deliver', String(body.deliver))
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
if (skills) {
if (skills.length === 0) {
args.push('--clear-skills')
} else {
for (const skill of skills) args.push('--skill', skill)
}
}
if (body.script != null) args.push('--script', String(body.script))
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
if (body.no_agent === false) args.push('--agent')
try {
await runHermesCron(profile, args)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function remove(ctx: Context) {
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'remove', ctx.params.id])
ctx.body = { ok: true }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function pause(ctx: Context) {
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'pause', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function resume(ctx: Context) {
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'resume', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function run(ctx: Context) {
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'run', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
@@ -0,0 +1,784 @@
import type { Context } from 'koa'
import { readFile } from 'fs/promises'
import { resolve, normalize } from 'path'
import { homedir } from 'os'
import * as kanbanCli from '../../services/hermes/hermes-kanban'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import {
searchSessionSummariesWithProfile,
getSessionDetailFromDbWithProfile,
getExactSessionDetailFromDbWithProfile,
findLatestExactSessionIdWithProfile,
} from '../../db/hermes/sessions-db'
import { listUserProfiles } from '../../db/hermes/users-store'
const DEFAULT_PROFILE = 'default'
function profileName(value: string | null | undefined): string {
return value?.trim() || DEFAULT_PROFILE
}
function requestedProfile(ctx: Context): string | null {
return ctx.state?.profile?.name || null
}
function allowedProfileSet(ctx: Context): Set<string> | null {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return null
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
}
function visibleProfileSet(ctx: Context): Set<string> | null {
return allowedProfileSet(ctx)
}
function canUseProfile(ctx: Context, profile: string | null | undefined): boolean {
const allowed = allowedProfileSet(ctx)
return !allowed || allowed.has(profileName(profile))
}
function denyProfileAccess(ctx: Context, profile: string | null | undefined): boolean {
if (canUseProfile(ctx, profile)) return false
ctx.status = 403
ctx.body = { error: `Profile "${profileName(profile)}" is not available for this user` }
return true
}
function taskAssigneeProfile(task: { assignee: string | null }): string {
return profileName(task.assignee)
}
function filterTasksByVisibleProfiles(ctx: Context, tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanTask[] {
const visible = visibleProfileSet(ctx)
if (!visible) return tasks
return tasks.filter(task => visible.has(taskAssigneeProfile(task)))
}
function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats {
const by_status: Record<string, number> = {}
const by_assignee: Record<string, number> = {}
for (const task of tasks) {
by_status[task.status] = (by_status[task.status] || 0) + 1
const assignee = taskAssigneeProfile(task)
by_assignee[assignee] = (by_assignee[assignee] || 0) + 1
}
return { by_status, by_assignee, total: tasks.length }
}
function assignableProfileNames(ctx: Context): Set<string> | null {
const user = ctx.state?.user
if (!user) return null
if (user.role === 'super_admin') return new Set(listProfileNamesFromDisk())
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
}
function assigneesForUser(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] {
const assignable = assignableProfileNames(ctx)
if (!assignable) return assignees
const byName = new Map<string, kanbanCli.KanbanAssignee>()
for (const assignee of assignees) {
const name = profileName(assignee.name)
if (assignable.has(name)) byName.set(name, { ...assignee, name })
}
for (const name of [...assignable].sort()) {
if (!byName.has(name)) byName.set(name, { name, on_disk: true, counts: null })
}
return [...byName.values()]
}
async function getVisibleTasksForBoard(ctx: Context, board: string, opts: {
status?: string
assignee?: string
tenant?: string
includeArchived?: boolean
} = {}): Promise<kanbanCli.KanbanTask[]> {
if (opts.assignee && denyProfileAccess(ctx, opts.assignee)) return []
const tasks = await kanbanCli.listTasks({
board,
status: opts.status,
assignee: opts.assignee,
tenant: opts.tenant,
includeArchived: opts.includeArchived,
})
return filterTasksByVisibleProfiles(ctx, tasks)
}
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
}
function firstQueryValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
function requestBoard(ctx: Context): string | null {
const rawBoard = firstQueryValue(ctx.query.board as string | string[] | undefined)
if (rawBoard !== undefined && !rawBoard.trim()) {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
return null
}
try {
return kanbanCli.normalizeBoardSlug(rawBoard)
} catch {
ctx.status = 400
ctx.body = { error: 'invalid board slug' }
return null
}
}
function validSeverity(value?: string): value is 'warning' | 'error' | 'critical' {
return value === undefined || value === 'warning' || value === 'error' || value === 'critical'
}
const MAX_LOG_TAIL_BYTES = 1_000_000
const MAX_DISPATCH_TASKS = 100
const MAX_DISPATCH_FAILURE_LIMIT = 100
const MAX_BULK_TASKS = 100
type PositiveIntegerResult = { value?: number; error?: string }
type StringResult = { value?: string; error?: string }
type BooleanResult = { value?: boolean; error?: string }
type BodyResult = { body: Record<string, unknown>; error?: string }
function optionalPositiveInteger(value: unknown, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
return { error: `${name} must be a positive integer` }
}
if (value > max) {
return { error: `${name} must be <= ${max}` }
}
return { value }
}
function optionalPositiveIntegerQuery(value: string | undefined, name: string, max: number): PositiveIntegerResult {
if (value === undefined || value === '') return {}
const numeric = Number(value)
if (!Number.isInteger(numeric) || numeric <= 0) {
return { error: `${name} must be a positive integer` }
}
if (numeric > max) {
return { error: `${name} must be <= ${max}` }
}
return { value: numeric }
}
function requestBody(ctx: Context): BodyResult {
const body = ctx.request.body
if (body === undefined || body === null) return { body: {} }
if (typeof body !== 'object' || Array.isArray(body)) {
return { body: {}, error: 'request body must be an object' }
}
return { body: body as Record<string, unknown> }
}
function optionalString(value: unknown, name: string): StringResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'string') return { error: `${name} must be a string` }
return { value }
}
function optionalNullableString(value: unknown, name: string): { value?: string | null; error?: string } {
if (value === undefined) return {}
if (value === null) return { value: null }
if (typeof value !== 'string') return { error: `${name} must be a string` }
return { value }
}
function hasOwn(body: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(body, key)
}
function optionalTaskStatus(value: unknown, name: string): { value?: kanbanCli.KanbanTaskStatus; error?: string } {
if (value === undefined || value === null) return {}
if (value !== 'triage' && value !== 'todo' && value !== 'ready' && value !== 'running' && value !== 'blocked' && value !== 'done' && value !== 'archived') {
return { error: `${name} must be a valid kanban task status` }
}
return { value }
}
function requiredNonEmptyString(value: unknown, name: string): StringResult {
if (typeof value !== 'string' || !value.trim()) return { error: `${name} is required` }
return { value }
}
function requiredNonEmptyStringArray(value: unknown, name: string): { value?: string[]; error?: string } {
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.trim())) {
return { error: `${name} is required` }
}
return { value }
}
function optionalBoolean(value: unknown, name: string): BooleanResult {
if (value === undefined || value === null) return {}
if (typeof value !== 'boolean') return { error: `${name} must be boolean` }
return { value }
}
function optionalInteger(value: unknown, name: string): PositiveIntegerResult {
if (value === undefined || value === null || value === '') return {}
if (typeof value !== 'number' || !Number.isInteger(value)) {
return { error: `${name} must be an integer` }
}
return { value }
}
function rejectBadRequest(ctx: Context, error?: string): boolean {
if (!error) return false
ctx.status = 400
ctx.body = { error }
return true
}
export async function listBoards(ctx: Context) {
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
try {
const boards = await kanbanCli.listBoards({ includeArchived })
ctx.body = { boards }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function createBoard(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const slug = requiredNonEmptyString(body.slug, 'slug')
const name = optionalString(body.name, 'name')
const description = optionalString(body.description, 'description')
const icon = optionalString(body.icon, 'icon')
const color = optionalString(body.color, 'color')
const switchCurrent = optionalBoolean(body.switchCurrent, 'switchCurrent')
if (rejectBadRequest(ctx, slug.error || name.error || description.error || icon.error || color.error || switchCurrent.error)) return
try {
const board = await kanbanCli.createBoard({
slug: slug.value!,
name: name.value,
description: description.value,
icon: icon.value,
color: color.value,
switchCurrent: switchCurrent.value,
})
ctx.body = { board }
} catch (err: any) {
ctx.status = err.message?.includes('Invalid kanban board slug') ? 400 : 500
ctx.body = { error: err.message }
}
}
export async function archiveBoard(ctx: Context) {
const slug = ctx.params.slug
if (!slug?.trim()) {
ctx.status = 400
ctx.body = { error: 'slug is required' }
return
}
try {
await kanbanCli.archiveBoard(slug)
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = err.message?.includes('default') || err.message?.includes('Invalid kanban board slug') ? 400 : 500
ctx.body = { error: err.message }
}
}
export async function capabilities(ctx: Context) {
try {
const capabilities = await kanbanCli.getCapabilities()
ctx.body = { capabilities }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function list(ctx: Context) {
const status = firstQueryValue(ctx.query.status as string | string[] | undefined)
const assignee = firstQueryValue(ctx.query.assignee as string | string[] | undefined)
const tenant = firstQueryValue(ctx.query.tenant as string | string[] | undefined)
const includeArchived = firstQueryValue(ctx.query.includeArchived as string | string[] | undefined) === 'true'
const board = requestBoard(ctx)
if (!board) return
try {
const tasks = await getVisibleTasksForBoard(ctx, board, { status, assignee, tenant, includeArchived })
if (ctx.status === 403) return
ctx.body = { tasks }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function get(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const detail = await kanbanCli.getTask(ctx.params.id, { board })
if (!detail) {
ctx.status = 404
ctx.body = { error: 'Task not found' }
return
}
if (!filterTasksByVisibleProfiles(ctx, [detail.task]).length) {
ctx.status = 404
ctx.body = { error: 'Task not found' }
return
}
// For terminal tasks, find related session from the worker's profile DB.
// Archived tasks can still carry the worker result/session users need to inspect.
if ((detail.task.status === 'done' || detail.task.status === 'archived') && detail.runs.length > 0) {
const profile = getLatestRunProfile(detail)
if (profile) {
try {
const exactSessionId = await findLatestExactSessionIdWithProfile(detail.task.id, profile)
if (exactSessionId) {
const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile)
if (sessionDetail) {
;(detail as any).session = {
id: exactSessionId,
title: sessionDetail.title,
source: sessionDetail.source,
model: sessionDetail.model,
started_at: sessionDetail.started_at,
ended_at: sessionDetail.ended_at,
messages: sessionDetail.messages,
}
}
} else {
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
if (results.length > 0) {
const sessionId = results[0].id
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
if (sessionDetail) {
;(detail as any).session = {
id: sessionId,
title: sessionDetail.title,
source: sessionDetail.source,
model: sessionDetail.model,
started_at: sessionDetail.started_at,
ended_at: sessionDetail.ended_at,
messages: sessionDetail.messages,
}
}
}
}
} catch {
// Session lookup is best-effort, don't fail the whole request
}
}
}
ctx.body = detail
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function create(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const title = requiredNonEmptyString(payload.title, 'title')
const body = optionalString(payload.body, 'body')
const assignee = optionalString(payload.assignee, 'assignee')
const priority = optionalInteger(payload.priority, 'priority')
const tenant = optionalString(payload.tenant, 'tenant')
if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return
const targetAssignee = assignee.value || requestedProfile(ctx) || undefined
if (targetAssignee && denyProfileAccess(ctx, targetAssignee)) return
const board = requestBoard(ctx)
if (!board) return
try {
const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: targetAssignee, priority: priority.value, tenant: tenant.value })
ctx.body = { task }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function complete(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const payload = bodyResult.body
const taskIds = requiredNonEmptyStringArray(payload.task_ids, 'task_ids')
const summary = optionalString(payload.summary, 'summary')
if (rejectBadRequest(ctx, taskIds.error || summary.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.completeTasks(taskIds.value!, summary.value, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function block(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const reason = requiredNonEmptyString(bodyResult.body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.blockTask(ctx.params.id, reason.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function unblock(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const taskIds = requiredNonEmptyStringArray(bodyResult.body.task_ids, 'task_ids')
if (rejectBadRequest(ctx, taskIds.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.unblockTasks(taskIds.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function assign(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile')
if (rejectBadRequest(ctx, profile.error)) return
if (denyProfileAccess(ctx, profile.value)) return
const board = requestBoard(ctx)
if (!board) return
try {
await kanbanCli.assignTask(ctx.params.id, profile.value!, { board })
ctx.body = { ok: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function addComment(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const bodyPayload = bodyResult.body
const body = requiredNonEmptyString(bodyPayload.body, 'body')
const author = optionalString(bodyPayload.author, 'author')
if (rejectBadRequest(ctx, body.error || author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.addComment(ctx.params.id, body.value!, { board, author: author.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function linkTasks(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const parentId = requiredNonEmptyString(bodyResult.body.parent_id, 'parent_id')
const childId = requiredNonEmptyString(bodyResult.body.child_id, 'child_id')
if (rejectBadRequest(ctx, parentId.error || childId.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.linkTasks(parentId.value!.trim(), childId.value!.trim(), { board })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function unlinkTasks(ctx: Context) {
const parentId = requiredNonEmptyString(firstQueryValue(ctx.query.parent_id as string | string[] | undefined), 'parent_id')
const childId = requiredNonEmptyString(firstQueryValue(ctx.query.child_id as string | string[] | undefined), 'child_id')
if (rejectBadRequest(ctx, parentId.error || childId.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.unlinkTasks(parentId.value!.trim(), childId.value!.trim(), { board })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function bulkUpdateTasks(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const ids = requiredNonEmptyStringArray(body.ids, 'ids')
const status = optionalTaskStatus(body.status, 'status')
const assignee = optionalNullableString(body.assignee, 'assignee')
const archive = optionalBoolean(body.archive, 'archive')
const summary = optionalString(body.summary, 'summary')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return
if (assignee.value && denyProfileAccess(ctx, assignee.value)) return
if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) {
ctx.status = 400
ctx.body = { error: 'at least one bulk action is required' }
return
}
if (ids.value!.length > MAX_BULK_TASKS) {
ctx.status = 400
ctx.body = { error: `ids must contain <= ${MAX_BULK_TASKS} tasks` }
return
}
if (archive.value && status.value !== undefined) {
ctx.status = 400
ctx.body = { error: 'archive cannot be combined with status' }
return
}
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.bulkUpdateTasks({
board,
ids: ids.value!.map(id => id.trim()),
status: status.value,
assignee: assignee.value,
archive: archive.value,
summary: summary.value,
reason: reason.value,
})
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function taskLog(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
const tailRaw = firstQueryValue(ctx.query.tail as string | string[] | undefined)
const tail = optionalPositiveIntegerQuery(tailRaw, 'tail', MAX_LOG_TAIL_BYTES)
if (rejectBadRequest(ctx, tail.error)) return
try {
ctx.body = await kanbanCli.getTaskLog(ctx.params.id, { board, tail: tail.value })
} catch (err: any) {
ctx.status = err.message?.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function diagnostics(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
const task = firstQueryValue(ctx.query.task as string | string[] | undefined)
const severity = firstQueryValue(ctx.query.severity as string | string[] | undefined)
if (!validSeverity(severity)) {
ctx.status = 400
ctx.body = { error: 'severity must be warning, error, or critical' }
return
}
try {
const diagnostics = await kanbanCli.getDiagnostics({ board, task, severity })
ctx.body = { diagnostics }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reclaim(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, reason.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reclaimTask(ctx.params.id, { board, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function reassign(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const profile = requiredNonEmptyString(body.profile, 'profile')
const reclaim = optionalBoolean(body.reclaim, 'reclaim')
const reason = optionalString(body.reason, 'reason')
if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return
if (denyProfileAccess(ctx, profile.value)) return
const board = requestBoard(ctx)
if (!board) return
try {
ctx.body = await kanbanCli.reassignTask(ctx.params.id, profile.value!, { board, reclaim: reclaim.value, reason: reason.value })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function specify(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const author = optionalString(body.author, 'author')
if (rejectBadRequest(ctx, author.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const results = await kanbanCli.specifyTask(ctx.params.id, { board, author: author.value })
ctx.body = { results }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function dispatch(ctx: Context) {
const bodyResult = requestBody(ctx)
if (rejectBadRequest(ctx, bodyResult.error)) return
const body = bodyResult.body
const dryRun = optionalBoolean(body.dryRun, 'dryRun')
const max = optionalPositiveInteger(body.max, 'max', MAX_DISPATCH_TASKS)
const failureLimit = optionalPositiveInteger(body.failureLimit, 'failureLimit', MAX_DISPATCH_FAILURE_LIMIT)
if (rejectBadRequest(ctx, dryRun.error || max.error || failureLimit.error)) return
const board = requestBoard(ctx)
if (!board) return
try {
const result = await kanbanCli.dispatch({ board, dryRun: dryRun.value, max: max.value, failureLimit: failureLimit.value })
ctx.body = { result }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function stats(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const visible = visibleProfileSet(ctx)
const stats = visible
? statsForTasks(await getVisibleTasksForBoard(ctx, board, { includeArchived: true }))
: await kanbanCli.getStats({ board })
ctx.body = { stats }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function assignees(ctx: Context) {
const board = requestBoard(ctx)
if (!board) return
try {
const assignees = assigneesForUser(ctx, await kanbanCli.getAssignees({ board }))
ctx.body = { assignees }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function readArtifact(ctx: Context) {
const filePath = ctx.query.path as string | undefined
if (!filePath) {
ctx.status = 400
ctx.body = { error: 'path is required' }
return
}
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
const resolved = resolve(normalize(filePath))
if (!isPathWithin(resolved, kanbanDir)) {
ctx.status = 403
ctx.body = { error: 'Path must be within kanban workspaces' }
return
}
try {
const data = await readFile(resolved, 'utf-8')
ctx.body = { content: data, path: filePath }
} catch (err: any) {
if (err.code === 'ENOENT') {
ctx.status = 404
ctx.body = { error: 'File not found' }
} else {
ctx.status = 500
ctx.body = { error: err.message }
}
}
}
export async function searchSessions(ctx: Context) {
const { task_id, profile, q } = ctx.query as {
task_id?: string
profile?: string
q?: string
}
if (!task_id || !profile) {
ctx.status = 400
ctx.body = { error: 'task_id and profile are required' }
return
}
if (denyProfileAccess(ctx, profile)) return
try {
if (!q) {
const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile)
if (exactSessionId) {
const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile)
if (sessionDetail) {
ctx.body = {
results: [{
id: exactSessionId,
source: sessionDetail.source,
title: sessionDetail.title,
preview: sessionDetail.preview,
model: sessionDetail.model,
started_at: sessionDetail.started_at,
ended_at: sessionDetail.ended_at,
last_active: sessionDetail.last_active,
message_count: sessionDetail.message_count,
tool_call_count: sessionDetail.tool_call_count,
input_tokens: sessionDetail.input_tokens,
output_tokens: sessionDetail.output_tokens,
cache_read_tokens: sessionDetail.cache_read_tokens,
cache_write_tokens: sessionDetail.cache_write_tokens,
reasoning_tokens: sessionDetail.reasoning_tokens,
billing_provider: sessionDetail.billing_provider,
estimated_cost_usd: sessionDetail.estimated_cost_usd,
actual_cost_usd: sessionDetail.actual_cost_usd,
cost_status: sessionDetail.cost_status,
matched_message_id: null,
snippet: sessionDetail.preview,
rank: 0,
}],
}
return
}
}
}
const searchQuery = q || task_id
const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10)
ctx.body = { results }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,123 @@
import { existsSync, statSync } from 'fs'
import { readFile } from 'fs/promises'
import { join } from 'path'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { config } from '../../config'
const WEBUI_LOG_FILE = join(config.appHome, 'logs', 'server.log')
const BRIDGE_LOG_FILE = join(config.appHome, 'logs', 'bridge.log')
interface LogEntry {
timestamp: string; level: string; logger: string; message: string; raw: string
}
function appendPinoContext(message: string, obj: any): string {
const parts: string[] = []
const runtime = obj.runtime && typeof obj.runtime === 'object' ? obj.runtime : null
if (runtime) {
if (runtime.profile) parts.push(`profile=${runtime.profile}`)
if (runtime.cwd) parts.push(`cwd=${runtime.cwd}`)
if (runtime.profile_dir) parts.push(`profile_dir=${runtime.profile_dir}`)
if (runtime.config_path) parts.push(`config=${runtime.config_path}`)
} else if (obj.profile) {
parts.push(`profile=${obj.profile}`)
}
if (obj.request?.action) parts.push(`action=${obj.request.action}`)
if (obj.err?.message) parts.push(`error=${obj.err.message}`)
if (obj.sessionId) parts.push(`session=${obj.sessionId}`)
if (obj.runId) parts.push(`run=${obj.runId}`)
if (obj.status) parts.push(`status=${obj.status}`)
return parts.length > 0 ? `${message} ${parts.join(' ')}` : message
}
function parseLine(line: string): LogEntry {
try {
const obj = JSON.parse(line)
if (obj.level && obj.time) {
const ts = new Date(obj.time).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')
const levelMap: Record<number, string> = { 10: 'TRACE', 20: 'DEBUG', 30: 'INFO', 40: 'WARN', 50: 'ERROR', 60: 'FATAL' }
// Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... }
const loggerName = obj.name || obj.logger || 'app'
const message = obj.msg || (obj.err ? obj.err.message : '')
const baseMessage = typeof message === 'string' ? message : JSON.stringify(message)
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: appendPinoContext(baseMessage, obj), raw: line }
}
} catch {}
let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
if (match) { return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } }
match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/)
if (match) { return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } }
return { timestamp: '', level: '', logger: '', message: line, raw: line }
}
export async function list(ctx: any) {
const files = await hermesCli.listLogFiles()
if (existsSync(WEBUI_LOG_FILE)) {
try {
const stat = statSync(WEBUI_LOG_FILE)
const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB`
const modified = stat.mtime.toLocaleString()
files.push({ name: 'webui', size, modified })
} catch { }
}
if (existsSync(BRIDGE_LOG_FILE)) {
try {
const stat = statSync(BRIDGE_LOG_FILE)
const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB`
const modified = stat.mtime.toLocaleString()
files.push({ name: 'bridge', size, modified })
} catch { }
}
ctx.body = { files }
}
export async function read(ctx: any) {
const logName = ctx.params.name
const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100
const level = (ctx.query.level as string) || undefined
const session = (ctx.query.session as string) || undefined
const since = (ctx.query.since as string) || undefined
if (logName === 'webui') {
try {
if (!existsSync(WEBUI_LOG_FILE)) { ctx.body = { entries: [] }; return }
const content = await readFile(WEBUI_LOG_FILE, 'utf-8')
const rawLines = content.split('\n')
const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines
const entries: LogEntry[] = []
for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) }
ctx.body = { entries: entries.reverse() }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
return
}
if (logName === 'bridge') {
try {
if (!existsSync(BRIDGE_LOG_FILE)) { ctx.body = { entries: [] }; return }
const content = await readFile(BRIDGE_LOG_FILE, 'utf-8')
const rawLines = content.split('\n')
const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines
const entries: LogEntry[] = []
for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) }
ctx.body = { entries: entries.reverse() }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
return
}
try {
const content = await hermesCli.readLogs(logName, lines, level, session, since)
const rawLines = content.split('\n')
const entries: (LogEntry | null)[] = []
for (const line of rawLines) {
if (line.startsWith('---') || line.trim() === '') continue
entries.push(parseLine(line))
}
ctx.body = { entries: entries.reverse() }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,120 @@
import type { Context } from 'koa'
import { bridgeMcpAction } from '../../services/hermes/mcp'
function getProfile(ctx: Context): string | undefined {
return (ctx.state as any)?.profile?.name || undefined
}
/** Validate server name: non-empty, no control chars, no path separators */
function isValidServerName(name: string): boolean {
if (!name || name.trim().length === 0) return false
if (name.length > 128) return false
// Reject path separators and control characters
if (/[/\\\x00-\x1f]/.test(name)) return false
return true
}
export async function listServers(ctx: Context) {
try {
ctx.body = await bridgeMcpAction('mcp_list', {}, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'MCP bridge not available' }
}
}
export async function addServer(ctx: Context) {
try {
const { name, config } = (ctx.request.body || {}) as Record<string, unknown>
if (typeof name !== 'string' || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
if (!config || typeof config !== 'object') {
ctx.status = 400
ctx.body = { error: 'config object is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_add', { name: name.trim(), config }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to add MCP server' }
}
}
export async function updateServer(ctx: Context) {
try {
const name = ctx.params.name as string
const { config } = (ctx.request.body || {}) as Record<string, unknown>
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
if (!config || typeof config !== 'object') {
ctx.status = 400
ctx.body = { error: 'config object is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_update', { name, config }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to update MCP server' }
}
}
export async function removeServer(ctx: Context) {
try {
const name = ctx.params.name as string
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_remove', { name }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to remove MCP server' }
}
}
export async function testServer(ctx: Context) {
try {
const name = ctx.params.name as string
if (!name || !isValidServerName(name)) {
ctx.status = 400
ctx.body = { error: 'Valid server name is required' }
return
}
ctx.body = await bridgeMcpAction('mcp_server_test', { name }, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to test MCP server' }
}
}
export async function listTools(ctx: Context) {
try {
const server = ctx.query.server as string | undefined
const raw = ctx.query.raw === '1' || ctx.query.raw === 'true'
const payload: Record<string, any> = {}
if (server) payload.server = server
if (raw) payload.raw = true
ctx.body = await bridgeMcpAction('mcp_tools_list', payload, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'MCP bridge not available' }
}
}
export async function reloadMcp(ctx: Context) {
try {
const server = ctx.query.server as string | undefined
const payload = server ? { server } : {}
ctx.body = await bridgeMcpAction('mcp_reload', payload, getProfile(ctx))
} catch (err: any) {
ctx.status = 503
ctx.body = { error: err.message || 'Failed to reload MCP' }
}
}
@@ -0,0 +1,614 @@
import type { Context } from 'koa'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, extname, isAbsolute, join, resolve } from 'path'
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { config } from '../../config'
import { readConfigYamlForProfile } from '../../services/config-helpers'
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
const XAI_VIDEO_MODEL = 'grok-imagine-video'
const APIKEY_IMAGE_PROVIDER = 'fun-codex'
const APIKEY_IMAGE_MODEL = 'gpt-image-2'
const APIKEY_IMAGE_TO_IMAGE_MODEL = 'gpt-5.4-mini'
const MAX_IMAGE_BYTES = 25 * 1024 * 1024
const DEFAULT_POLL_INTERVAL_MS = 5000
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
type AuthJson = {
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
}
type ApiKeyImageMode = 'text' | 'image' | 'edit'
type FunCodexProvider = {
apiKey: string
baseUrl: string
model: string
}
function requestedProfileName(ctx: Context): string {
const headerProfile = ctx.get('x-hermes-profile')
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
const body = ctx.request.body as { profile?: unknown } | undefined
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
return (ctx.state.profile?.name || headerProfile || queryProfile || bodyProfile || '').trim()
}
function resolveMediaProfile(ctx: Context): string {
let requested = requestedProfileName(ctx)
if (!requested && ctx.state.user?.role !== 'super_admin' && !ctx.state.serverTokenAuth) {
const profiles = ctx.state.user?.profiles || []
if (profiles.length === 1) {
requested = profiles[0]
} else {
const err: any = new Error('Profile is required')
err.status = 400
err.code = 'profile_required'
throw err
}
}
const profile = requested || getActiveProfileName() || 'default'
if (!listProfileNamesFromDisk().includes(profile)) {
const err: any = new Error(`Profile "${profile}" does not exist`)
err.status = 404
err.code = 'profile_not_found'
throw err
}
return profile
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
function readJsonFile(path: string): any {
try {
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return null
}
}
function buildApiUrl(baseUrl: string, pathWithV1: string): string {
const base = (baseUrl || 'https://api.apikey.fun/v1').replace(/\/+$/, '')
const apiPath = pathWithV1.startsWith('/') ? pathWithV1 : `/${pathWithV1}`
if (base.endsWith('/v1') && apiPath.startsWith('/v1/')) return `${base}${apiPath.slice(3)}`
return `${base}${apiPath}`
}
async function resolveFunCodexProvider(profile: string): Promise<FunCodexProvider | null> {
const hermesConfig = await readConfigYamlForProfile(profile)
const customProviders = Array.isArray(hermesConfig.custom_providers)
? hermesConfig.custom_providers as any[]
: []
const provider = customProviders.find(entry => String(entry?.name || '').trim() === APIKEY_IMAGE_PROVIDER)
const apiKey = String(provider?.api_key || '').trim()
const baseUrl = String(provider?.base_url || '').trim()
if (!provider || !apiKey || !baseUrl) return null
return {
apiKey,
baseUrl,
model: String(provider?.model || '').trim(),
}
}
function resolveXaiToken(profile: string): { token: string; source: string } | null {
const envToken = String(process.env.XAI_API_KEY || '').trim()
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
const auth = readJsonFile(authPathForProfile(profile)) as AuthJson | null
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
const pool = auth?.credential_pool?.['xai-oauth']
if (Array.isArray(pool)) {
const poolToken = String(pool.find(entry => entry?.access_token)?.access_token || '').trim()
if (poolToken) return { token: poolToken, source: 'xai-oauth' }
}
return null
}
function mimeFromPath(path: string): string | null {
const ext = extname(path).toLowerCase()
if (ext === '.png') return 'image/png'
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.webp') return 'image/webp'
return null
}
function mimeFromMagic(buffer: Buffer): string | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'
return null
}
function imagePathToDataUri(imagePath: string): string {
const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath)
const image = readFileSync(resolvedPath)
if (image.length > MAX_IMAGE_BYTES) {
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
err.status = 413
throw err
}
const mime = mimeFromMagic(image) || mimeFromPath(resolvedPath)
if (!mime) {
const err: any = new Error('unsupported image type; use png, jpeg, or webp')
err.status = 400
throw err
}
return `data:${mime};base64,${image.toString('base64')}`
}
function normalizeImageInput(body: any): string {
const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : ''
if (imageUrl) return imageUrl
const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : ''
if (imageBase64) {
if (imageBase64.startsWith('data:image/')) return imageBase64
const mime = typeof body.mime_type === 'string' ? body.mime_type.trim() : ''
if (!mime.startsWith('image/')) {
const err: any = new Error('mime_type is required when image_base64 is not a data URI')
err.status = 400
throw err
}
return `data:${mime};base64,${imageBase64}`
}
const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : ''
if (!imagePath) {
const err: any = new Error('image_path, image_url, or image_base64 is required')
err.status = 400
throw err
}
if (!existsSync(isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath))) {
const err: any = new Error('image_path does not exist')
err.status = 404
throw err
}
return imagePathToDataUri(imagePath)
}
function imageDataUriToBytes(dataUri: string): { buffer: Buffer; mime: string; name: string } {
const match = dataUri.match(/^data:([^;,]+);base64,(.+)$/)
if (!match) {
const err: any = new Error('image_base64 must be a valid image data URI for edit mode')
err.status = 400
throw err
}
const mime = match[1]
if (!mime.startsWith('image/')) {
const err: any = new Error('image data URI must use an image mime type')
err.status = 400
throw err
}
return {
buffer: Buffer.from(match[2], 'base64'),
mime,
name: `source.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`,
}
}
async function fetchImageBytes(url: string): Promise<{ buffer: Buffer; mime: string; name: string }> {
const res = await fetch(url)
if (!res.ok) {
const err: any = new Error(`image_url fetch failed: ${res.status} ${res.statusText}`)
err.status = 400
throw err
}
const mime = String(res.headers.get('content-type') || '').split(';')[0] || 'image/png'
if (!mime.startsWith('image/')) {
const err: any = new Error('image_url did not return an image')
err.status = 400
throw err
}
const buffer = Buffer.from(await res.arrayBuffer())
if (buffer.length > MAX_IMAGE_BYTES) {
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
err.status = 413
throw err
}
const name = new URL(url).pathname.split('/').pop() || 'source.png'
return { buffer, mime, name }
}
async function normalizeImageFile(body: any): Promise<{ buffer: Buffer; mime: string; name: string }> {
const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : ''
if (imageUrl) return fetchImageBytes(imageUrl)
const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : ''
if (imageBase64) {
const dataUri = imageBase64.startsWith('data:image/')
? imageBase64
: `data:${String(body.mime_type || '').trim()};base64,${imageBase64}`
return imageDataUriToBytes(dataUri)
}
const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : ''
if (!imagePath) {
const err: any = new Error('image_path, image_url, or image_base64 is required')
err.status = 400
throw err
}
const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath)
if (!existsSync(resolvedPath)) {
const err: any = new Error('image_path does not exist')
err.status = 404
throw err
}
const buffer = readFileSync(resolvedPath)
if (buffer.length > MAX_IMAGE_BYTES) {
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
err.status = 413
throw err
}
const mime = mimeFromMagic(buffer) || mimeFromPath(resolvedPath)
if (!mime) {
const err: any = new Error('unsupported image type; use png, jpeg, or webp')
err.status = 400
throw err
}
return { buffer, mime, name: resolvedPath.split(/[\\/]/).pop() || 'source.png' }
}
function normalizeDuration(value: unknown): number {
const duration = Number(value || 8)
if (!Number.isFinite(duration) || duration < 1 || duration > 15) {
const err: any = new Error('duration must be between 1 and 15 seconds')
err.status = 400
throw err
}
return duration
}
export function defaultMediaOutputPath(requestId: string, now = new Date()): string {
const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `video_${now.getTime()}`
return join(config.appHome, 'media', `${safeRequestId}.mp4`)
}
export function defaultImageOutputPath(requestId: string, index = 0): string {
const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `image_${Date.now()}`
const suffix = index > 0 ? `-${index + 1}` : ''
return join(config.appHome, 'media', `${safeRequestId}${suffix}.png`)
}
function normalizeImageMode(value: unknown): ApiKeyImageMode {
const mode = String(value || 'text').trim().toLowerCase()
if (mode === 'text' || mode === 'image' || mode === 'edit') return mode
const err: any = new Error('mode must be one of text, image, or edit')
err.status = 400
throw err
}
function normalizePositiveInt(value: unknown, fallback: number, key: string): number {
const parsed = Number(value || fallback)
if (!Number.isFinite(parsed) || parsed < 1) {
const err: any = new Error(`${key} must be a positive number`)
err.status = 400
throw err
}
return Math.floor(parsed)
}
function collectImageBase64(event: any, images: string[] = []): string[] {
if (!event || typeof event !== 'object') return images
for (const key of ['b64_json', 'base64', 'image_base64', 'partial_image_b64']) {
if (typeof event[key] === 'string' && event[key]) images.push(event[key])
}
for (const item of event.data || []) collectImageBase64(item, images)
for (const item of event.response?.output || []) {
if (typeof item?.result === 'string' && item.result) images.push(item.result)
collectImageBase64(item, images)
}
if (typeof event.item?.result === 'string' && event.item.result) images.push(event.item.result)
return images
}
function isPartialImageEvent(event: any): boolean {
return event?.type === 'image_generation.partial_image' ||
event?.type === 'response.image_generation_call.partial_image'
}
function throwIfImageStreamError(event: any): void {
if (event?.type !== 'error' && event?.type !== 'response.failed') return
const err: any = new Error(event?.response?.error?.message || event?.error?.message || 'image generation failed')
err.status = 502
throw err
}
async function readSseImageResults(res: Response, limit: number): Promise<string[]> {
if (!res.body) throw new Error('image generation response is not readable')
const reader = res.body.getReader()
const decoder = new TextDecoder()
const images: string[] = []
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const frames = buffer.split(/\r?\n\r?\n/)
buffer = frames.pop() || ''
for (const frame of frames) {
const data = frame
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trimStart())
.join('\n')
.trim()
if (!data || data === '[DONE]') continue
const event = JSON.parse(data)
throwIfImageStreamError(event)
if (isPartialImageEvent(event)) continue
collectImageBase64(event, images)
if (images.length >= limit) return images.slice(0, limit)
}
}
return images.slice(0, limit)
}
async function requestApiKeyImage(provider: FunCodexProvider, mode: ApiKeyImageMode, body: any): Promise<string[]> {
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
if (!prompt) {
const err: any = new Error('prompt is required')
err.status = 400
throw err
}
const n = normalizePositiveInt(body.n, 1, 'n')
const timeoutMs = normalizePositiveInt(body.timeout_ms, DEFAULT_TIMEOUT_MS, 'timeout_ms')
const headers = {
Accept: 'text/event-stream',
Authorization: `Bearer ${provider.apiKey}`,
}
let res: Response
if (mode === 'text') {
res = await fetch(buildApiUrl(provider.baseUrl, '/v1/images/generations'), {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
body: JSON.stringify({
model: body.model || APIKEY_IMAGE_MODEL,
prompt,
n,
size: body.size || '1024x1024',
quality: body.quality || 'auto',
stream: true,
response_format: 'b64_json',
}),
})
} else if (mode === 'image') {
res = await fetch(buildApiUrl(provider.baseUrl, '/v1/responses'), {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
body: JSON.stringify({
model: body.model || provider.model || APIKEY_IMAGE_TO_IMAGE_MODEL,
stream: true,
input: [{
role: 'user',
content: [
{ type: 'input_text', text: prompt },
{ type: 'input_image', image_url: normalizeImageInput(body) },
],
}],
tools: [{
type: 'image_generation',
model: body.image_model || APIKEY_IMAGE_MODEL,
size: body.size || '1024x1024',
quality: body.quality || 'auto',
output_format: body.output_format || 'png',
}],
tool_choice: { type: 'image_generation' },
}),
})
} else {
const image = await normalizeImageFile(body)
const imageBytes = new Uint8Array(image.buffer.byteLength)
imageBytes.set(image.buffer)
const form = new FormData()
form.append('image', new Blob([imageBytes.buffer], { type: image.mime }), image.name)
form.append('prompt', prompt)
form.append('model', body.model || APIKEY_IMAGE_MODEL)
form.append('n', String(n))
form.append('quality', body.quality || 'auto')
form.append('size', body.size || '1024x1024')
form.append('stream', 'true')
form.append('response_format', 'b64_json')
res = await fetch(buildApiUrl(provider.baseUrl, '/v1/images/edits'), {
method: 'POST',
headers,
signal: AbortSignal.timeout(timeoutMs),
body: form,
})
}
if (!res.ok) {
const detail = await res.text().catch(() => '')
const err: any = new Error(`image generation request failed: ${res.status} ${detail || res.statusText}`)
err.status = res.status === 401 || res.status === 403 ? 502 : 502
throw err
}
const images = await readSseImageResults(res, n)
if (images.length === 0) {
const err: any = new Error('image generation stream ended without image data')
err.status = 502
throw err
}
return images
}
function saveGeneratedImages(images: string[], requestedOutputPath?: string): string[] {
return images.map((image, index) => {
const outputPath = requestedOutputPath && images.length === 1
? requestedOutputPath
: requestedOutputPath
? requestedOutputPath.replace(/(\.[^.\\/]+)?$/, `${index > 0 ? `-${index + 1}` : ''}$1`)
: defaultImageOutputPath(`image_${Date.now()}`, index)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, Buffer.from(image, 'base64'))
return outputPath
})
}
export async function apiKeyImageGenerate(ctx: Context) {
let profile: string
try {
profile = resolveMediaProfile(ctx)
} catch (err: any) {
ctx.status = err.status || 400
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
return
}
const provider = await resolveFunCodexProvider(profile)
if (!provider) {
ctx.status = 401
ctx.body = {
error: `Missing fun-codex provider in profile "${profile}" config.yaml.`,
code: 'missing_fun_codex_provider',
}
return
}
const body = ctx.request.body as any
try {
const mode = normalizeImageMode(body.mode)
const images = await requestApiKeyImage(provider, mode, body)
const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : ''
const outputPaths = saveGeneratedImages(images, requestedOutputPath || undefined)
ctx.body = {
ok: true,
mode,
output_paths: outputPaths,
provider: APIKEY_IMAGE_PROVIDER,
base_url: provider.baseUrl,
profile,
}
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = {
error: err.message || String(err),
code: err.code || 'image_generation_failed',
}
}
}
async function requestXaiJson(url: string, token: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...(init.headers || {}),
},
})
const text = await res.text()
let data: any = null
try { data = text ? JSON.parse(text) : null } catch {}
if (!res.ok) {
const detail = data?.error?.message || data?.error || text || res.statusText
const err: any = new Error(`xAI request failed: ${res.status} ${detail}`)
err.status = res.status === 401 || res.status === 403 ? 502 : 502
throw err
}
return data
}
async function downloadVideo(url: string, outputPath: string): Promise<void> {
const res = await fetch(url)
if (!res.ok) throw new Error(`failed to download generated video: ${res.status} ${res.statusText}`)
const arrayBuffer = await res.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, buffer)
}
export async function grokImageToVideo(ctx: Context) {
let profile: string
try {
profile = resolveMediaProfile(ctx)
} catch (err: any) {
ctx.status = err.status || 400
ctx.body = { error: err.message || String(err), code: err.code || 'invalid_profile' }
return
}
const tokenInfo = resolveXaiToken(profile)
if (!tokenInfo) {
ctx.status = 401
ctx.body = {
error: `Missing xAI token for profile "${profile}". Set XAI_API_KEY or complete xAI OAuth login first.`,
code: 'missing_xai_token',
}
return
}
const body = ctx.request.body as any
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
if (!prompt) {
ctx.status = 400
ctx.body = { error: 'prompt is required', code: 'missing_prompt' }
return
}
try {
const image = normalizeImageInput(body)
const duration = normalizeDuration(body.duration)
const rawTimeoutMs = Number(body.timeout_ms || DEFAULT_TIMEOUT_MS)
const timeoutMs = Number.isFinite(rawTimeoutMs)
? Math.max(10000, Math.min(rawTimeoutMs, 30 * 60 * 1000))
: DEFAULT_TIMEOUT_MS
const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : ''
const started = await requestXaiJson(XAI_VIDEO_GENERATIONS_URL, tokenInfo.token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: XAI_VIDEO_MODEL,
prompt,
image: { url: image },
duration,
}),
})
const requestId = String(started?.request_id || '').trim()
if (!requestId) throw new Error('xAI response missing request_id')
const deadline = Date.now() + timeoutMs
let latest: any = null
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS))
latest = await requestXaiJson(`${XAI_VIDEO_STATUS_URL}/${encodeURIComponent(requestId)}`, tokenInfo.token)
if (latest?.status === 'done') {
const videoUrl = String(latest?.video?.url || '').trim()
const outputPath = requestedOutputPath || defaultMediaOutputPath(requestId)
if (videoUrl) await downloadVideo(videoUrl, outputPath)
ctx.body = {
request_id: requestId,
status: latest.status,
video_url: videoUrl,
output_path: outputPath,
token_source: tokenInfo.source,
profile,
}
return
}
if (latest?.status === 'expired' || latest?.status === 'failed' || latest?.status === 'error') {
ctx.status = 502
ctx.body = { request_id: requestId, status: latest.status, error: latest?.error || 'xAI video generation failed' }
return
}
}
ctx.status = 504
ctx.body = { request_id: requestId, status: latest?.status || 'pending', error: 'Timed out waiting for xAI video generation' }
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || String(err) }
}
}
@@ -0,0 +1,57 @@
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { safeReadFile, safeStat } from '../../services/config-helpers'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
function requestProfileDir(ctx: any): string {
return getProfileDir(requestedProfile(ctx))
}
export async function get(ctx: any) {
const hd = requestProfileDir(ctx)
const memoryPath = join(hd, 'memories', 'MEMORY.md')
const userPath = join(hd, 'memories', 'USER.md')
const soulPath = join(hd, 'SOUL.md')
const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([
safeReadFile(memoryPath), safeReadFile(userPath), safeReadFile(soulPath),
safeStat(memoryPath), safeStat(userPath), safeStat(soulPath),
])
ctx.body = {
memory: memory || '', user: user || '', soul: soul || '',
memory_mtime: memoryStat?.mtime || null, user_mtime: userStat?.mtime || null, soul_mtime: soulStat?.mtime || null,
}
}
export async function save(ctx: any) {
const { section, content } = ctx.request.body as { section: string; content: string }
if (!section || !content) {
ctx.status = 400
ctx.body = { error: 'Missing section or content' }
return
}
if (section !== 'memory' && section !== 'user' && section !== 'soul') {
ctx.status = 400
ctx.body = { error: 'Section must be "memory", "user", or "soul"' }
return
}
let filePath: string
const hd = requestProfileDir(ctx)
if (section === 'soul') {
filePath = join(hd, 'SOUL.md')
} else {
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
await mkdir(join(hd, 'memories'), { recursive: true })
filePath = join(hd, 'memories', fileName)
}
try {
await writeFile(filePath, content, 'utf-8')
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,314 @@
import { randomUUID } from 'crypto'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { dirname, join } from 'path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { logger } from '../../services/logger'
// --- Nous Portal OAuth Constants ---
const NOUS_PORTAL_URL = 'https://portal.nousresearch.com'
const NOUS_CLIENT_ID = 'hermes-cli'
const NOUS_SCOPE = 'inference:mint_agent_key'
const POLL_MAX_DURATION = 15 * 60 * 1000
const POLL_DEFAULT_INTERVAL = 5000
// --- Session Store ---
interface NousSession {
id: string
profile: string
deviceCode: string
userCode: string
verificationUrl: string
verificationUrlComplete: string
expiresIn: number
interval: number
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error?: string
createdAt: number
}
const sessions = new Map<string, NousSession>()
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((s, id) => { if (now - s.createdAt > POLL_MAX_DURATION + 60000) sessions.delete(id) })
}
// --- Auth file helpers ---
interface AuthJson {
version?: number
active_provider?: string
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
updated_at?: string
}
function loadAuthJson(authPath: string): AuthJson {
try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } }
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = dirname(authPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function requestedProfile(ctx: any): string {
const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : ''
const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : ''
const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : ''
return ctx.state?.profile?.name ||
headerProfile.trim() ||
queryProfile.trim() ||
bodyProfile.trim() ||
getActiveProfileName() ||
'default'
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
export function saveNousOAuthTokensForProfile(
profile: string,
tokenData: {
access_token: string
refresh_token?: string
expires_in?: number
inference_base_url?: string
},
agentKey = '',
agentKeyExpiresAt = '',
): void {
const inferenceBaseUrl = tokenData.inference_base_url || 'https://inference-api.nousresearch.com/v1'
const auth = loadAuthJson(authPathForProfile(profile))
if (!auth.providers) auth.providers = {}
const now = new Date()
auth.providers['nous'] = {
portal_base_url: NOUS_PORTAL_URL,
inference_base_url: inferenceBaseUrl,
client_id: NOUS_CLIENT_ID,
scope: NOUS_SCOPE,
token_type: 'Bearer',
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token || null,
obtained_at: now.toISOString(),
expires_at: tokenData.expires_in ? new Date(now.getTime() + tokenData.expires_in * 1000).toISOString() : null,
agent_key: agentKey || null,
agent_key_expires_at: agentKeyExpiresAt || null,
agent_key_obtained_at: agentKey ? now.toISOString() : null,
}
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['nous'] = [{
id: `nous-${Date.now()}`,
label: 'Nous Portal',
auth_type: 'oauth',
source: 'device_code',
priority: 0,
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token || null,
portal_base_url: NOUS_PORTAL_URL,
inference_base_url: inferenceBaseUrl,
agent_key: agentKey || null,
agent_key_expires_at: agentKeyExpiresAt || null,
base_url: inferenceBaseUrl,
}]
saveAuthJson(authPathForProfile(profile), auth)
}
// --- Background poll worker ---
async function nousLoginWorker(session: NousSession): Promise<void> {
const startTime = Date.now()
let interval = session.interval || POLL_DEFAULT_INTERVAL
while (Date.now() - startTime < POLL_MAX_DURATION) {
await new Promise(resolve => setTimeout(resolve, interval))
if (session.status !== 'pending') return
try {
const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: NOUS_CLIENT_ID,
device_code: session.deviceCode,
}).toString(),
signal: AbortSignal.timeout(15000),
})
if (res.ok) {
const tokenData = await res.json() as {
access_token: string
refresh_token?: string
expires_in?: number
inference_base_url?: string
}
// Mint agent key
const inferenceBaseUrl = tokenData.inference_base_url || 'https://inference-api.nousresearch.com/v1'
let agentKey = ''
let agentKeyExpiresAt = ''
try {
const mintRes = await fetch(`${NOUS_PORTAL_URL}/api/oauth/agent-key`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${tokenData.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ min_ttl_seconds: 1800 }),
signal: AbortSignal.timeout(15000),
})
if (mintRes.ok) {
const mintData = await mintRes.json() as {
api_key: string
expires_at: string
inference_base_url?: string
}
agentKey = mintData.api_key
agentKeyExpiresAt = mintData.expires_at
if (mintData.inference_base_url) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void mintData.inference_base_url
}
}
} catch (err: any) {
logger.warn(err, 'Nous agent key minting failed, proceeding without')
}
saveNousOAuthTokensForProfile(session.profile, tokenData, agentKey, agentKeyExpiresAt)
session.status = 'approved'
logger.info('Nous login successful')
return
}
// Parse error
const errData = await res.json().catch(() => ({}))
const errorCode = errData.error
if (errorCode === 'authorization_pending') {
continue
}
if (errorCode === 'slow_down') {
interval = Math.min(interval + 1000, 30000)
continue
}
if (errorCode === 'access_denied' || errorCode === 'expired_token') {
session.status = errorCode === 'access_denied' ? 'denied' : 'expired'
return
}
logger.error('Nous poll error: %s %s', res.status, errorCode)
session.status = 'error'
session.error = `OAuth error: ${errorCode}`
return
} catch (err: any) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') continue
logger.error(err, 'Nous poll error')
session.status = 'error'
session.error = err.message
return
}
}
session.status = 'expired'
}
// --- Controller functions ---
export async function start(ctx: any) {
try {
cleanupExpiredSessions()
const res = await fetch(`${NOUS_PORTAL_URL}/api/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: new URLSearchParams({
client_id: NOUS_CLIENT_ID,
scope: NOUS_SCOPE,
}).toString(),
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
let errorBody: any = null
try { errorBody = await res.json() } catch { }
logger.error('Nous device code request failed: %d %s', res.status, errorBody)
ctx.status = 502
ctx.body = { error: `Nous Portal error: ${res.status}` }
return
}
const data = await res.json() as {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}
const sessionId = randomUUID()
const session: NousSession = {
id: sessionId,
profile: requestedProfile(ctx),
deviceCode: data.device_code,
userCode: data.user_code,
verificationUrl: data.verification_uri,
verificationUrlComplete: data.verification_uri_complete,
expiresIn: data.expires_in,
interval: data.interval,
status: 'pending',
createdAt: Date.now(),
}
sessions.set(sessionId, session)
nousLoginWorker(session).catch(err => {
logger.error(err, 'Nous login worker error')
session.status = 'error'
session.error = err.message
})
ctx.body = {
session_id: sessionId,
user_code: data.user_code,
verification_url: data.verification_uri_complete,
expires_in: data.expires_in,
}
} catch (err: any) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
ctx.status = 504
ctx.body = { error: 'Nous Portal timeout' }
return
}
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function poll(ctx: any) {
const session = sessions.get(ctx.params.sessionId)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { status: session.status, error: session.error || null }
}
export async function status(ctx: any) {
try {
const authPath = authPathForProfile(requestedProfile(ctx))
const auth = loadAuthJson(authPath)
const nousProvider = auth.providers?.['nous']
if (!nousProvider?.access_token) {
ctx.body = { authenticated: false }
return
}
ctx.body = { authenticated: true }
} catch {
ctx.body = { authenticated: false }
}
}
@@ -0,0 +1,9 @@
import { createEmptyOpsRuntimeSnapshot, getOpsRuntimeSnapshot } from '../../services/hermes/ops-monitor'
export async function runtime(ctx: any) {
try {
ctx.body = await getOpsRuntimeSnapshot()
} catch (err: any) {
ctx.body = createEmptyOpsRuntimeSnapshot(err?.message || 'Failed to read performance metrics')
}
}
@@ -0,0 +1,10 @@
import { listHermesPlugins } from '../../services/hermes/plugins'
export async function list(ctx: any) {
try {
ctx.body = await listHermesPlugins(ctx.state?.profile?.name)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message || 'Failed to discover Hermes plugins' }
}
}
@@ -0,0 +1,825 @@
import { createReadStream, existsSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import { getWebUiHome } from '../../config'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { SessionDeleter } from '../../services/hermes/session-deleter'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
import {
getGatewayRuntimeStatusForProfile,
restartGatewayForProfile as restartGatewayRuntimeForProfile,
} from '../../services/hermes/gateway-autostart'
import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { HermesSkillInjector } from '../../services/hermes/skill-injector'
import type { HermesProfile } from '../../services/hermes/hermes-cli'
import { listUserProfiles } from '../../db/hermes/users-store'
const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 })
interface ProfileAvatarMeta {
type: 'generated' | 'image'
seed?: string
file?: string
mime?: string
updatedAt?: number
}
interface ProfileAvatarResponse {
type: 'generated' | 'image'
seed?: string
dataUrl?: string
updatedAt?: number
}
type RuntimeStatus = Awaited<ReturnType<typeof buildRuntimeStatus>>
interface RuntimeStatusCacheEntry {
status: RuntimeStatus
updatedAt: number
}
const runtimeStatusCache = new Map<string, RuntimeStatusCacheEntry>()
let runtimeStatusRefreshPromise: Promise<void> | null = null
let runtimeStatusMinimumFreshAt = 0
const RESERVED_PROFILE_NAMES = new Set([
'hermes', 'default', 'test', 'tmp', 'root', 'sudo',
])
const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([
'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout',
'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools',
'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall',
'profile', 'plugins', 'honcho', 'acp',
])
function normalizeProfileName(name: string): string {
return String(name || '').trim().toLowerCase()
}
function isForbiddenProfileName(name: string): boolean {
const normalized = normalizeProfileName(name)
if (!normalized || normalized === 'default') return false
return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized)
}
function getActiveProfileFile(): string {
return join(detectHermesRootHome(), 'active_profile')
}
function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
const base = detectHermesRootHome()
const profiles: HermesProfile[] = [{
name: 'default',
active: activeProfileName === 'default',
model: '—',
alias: '',
}]
const profilesDir = join(base, 'profiles')
if (!existsSync(profilesDir)) return profiles
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const name = entry.name
const dir = join(profilesDir, name)
if (!existsSync(join(dir, 'config.yaml')) && !existsSync(dir)) continue
profiles.push({
name,
active: name === activeProfileName,
model: '—',
alias: '',
})
}
return profiles
}
function profileExistsForManualSwitch(name: string): boolean {
const base = detectHermesRootHome()
if (!name || name === 'default') return true
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
}
async function injectBundledSkillsForProfile(name: string): Promise<void> {
try {
const targetDir = HermesSkillInjector.resolveTargetDirForProfile(name)
const result = await new HermesSkillInjector(undefined, targetDir).injectMissingSkills()
const target = result.targets[0]
if (target && (target.injected.length > 0 || target.updated.length > 0)) {
logger.info({
profile: name,
targetDir,
injected: target.injected,
updated: target.updated,
}, '[profiles] synced bundled skills for profile')
}
} catch (err: any) {
logger.warn(err, '[profiles] failed to sync bundled skills for profile "%s"', name)
}
}
function deleteForbiddenProfileFromDisk(name: string): boolean {
if (!isForbiddenProfileName(name)) return false
const base = detectHermesRootHome()
const profileDir = join(base, 'profiles', name)
if (!existsSync(profileDir)) return false
rmSync(profileDir, { recursive: true, force: true })
try {
if (normalizeProfileName(getActiveProfileName()) === normalizeProfileName(name)) {
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
}
} catch {}
logger.warn('[deleteProfile] removed reserved profile "%s" from disk after Hermes CLI rejected deletion', name)
return true
}
function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] {
return profiles.filter(profile => !isForbiddenProfileName(profile.name))
}
function requestedProfileName(ctx: any): string {
return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName()
}
function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return profiles
const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
return profiles.filter(profile => allowed.has(profile.name))
}
function canAccessProfile(ctx: any, profileName: string): boolean {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return true
return listUserProfiles(user.id).some(profile => profile.profile_name === profileName)
}
function denyProfile(ctx: any, profileName: string): boolean {
if (canAccessProfile(ctx, profileName)) return false
ctx.status = 403
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
return true
}
function profileMetadataRoot(): string {
return join(getWebUiHome(), 'profile-metadata')
}
function profileMetadataDir(name: string): string {
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
return join(profileMetadataRoot(), segment)
}
function profileAvatarMetaPath(name: string): string {
return join(profileMetadataDir(name), 'avatar.json')
}
function profileAvatarImagePath(name: string, file = 'avatar.bin'): string {
return join(profileMetadataDir(name), file)
}
function readProfileAvatar(name: string): ProfileAvatarResponse | null {
const metaPath = profileAvatarMetaPath(name)
if (!existsSync(metaPath)) return null
try {
const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as ProfileAvatarMeta
if (meta.type === 'generated') {
return {
type: 'generated',
seed: typeof meta.seed === 'string' ? meta.seed : name,
updatedAt: meta.updatedAt,
}
}
if (meta.type === 'image' && meta.file && meta.mime) {
const imagePath = profileAvatarImagePath(name, meta.file)
if (!existsSync(imagePath)) return null
const data = readFileSync(imagePath).toString('base64')
return {
type: 'image',
dataUrl: `data:${meta.mime};base64,${data}`,
updatedAt: meta.updatedAt,
}
}
} catch (err) {
logger.warn(err, '[profiles] failed to read avatar metadata for profile "%s"', name)
}
return null
}
function attachProfileAvatars<T extends HermesProfile>(profiles: T[]): Array<T & { avatar: ProfileAvatarResponse | null }> {
return profiles.map(profile => ({
...profile,
avatar: readProfileAvatar(profile.name),
}))
}
function parseAvatarDataUrl(dataUrl: string): { mime: string; buffer: Buffer } {
const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/)
if (!match) throw new Error('Avatar image must be a PNG, JPEG, or WebP data URL')
const buffer = Buffer.from(match[2], 'base64')
if (buffer.length > 1024 * 1024) throw new Error('Avatar image must be 1MB or smaller')
return { mime: match[1], buffer }
}
function removeProfileMetadata(name: string): void {
rmSync(profileMetadataDir(name), { recursive: true, force: true })
}
function renameProfileMetadata(oldName: string, newName: string): void {
const oldDir = profileMetadataDir(oldName)
const newDir = profileMetadataDir(newName)
if (!existsSync(oldDir) || oldDir === newDir) return
rmSync(newDir, { recursive: true, force: true })
renameSync(oldDir, newDir)
}
async function useProfileWithFallback(name: string): Promise<string> {
if (isForbiddenProfileName(name)) {
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
}
try {
return await hermesCli.useProfile(name)
} catch (err: any) {
if (!profileExistsForManualSwitch(name)) throw err
const base = detectHermesRootHome()
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
return `Switched to profile ${name}`
}
}
async function readBridgeWorkers(): Promise<{ reachable: boolean; workers: Record<string, boolean>; error?: string }> {
try {
const result = await new AgentBridgeClient({ timeoutMs: 5000 }).ping()
return {
reachable: true,
workers: ((result as any).workers || {}) as Record<string, boolean>,
}
} catch (err: any) {
return {
reachable: false,
workers: {},
error: err?.message || 'Bridge broker is not reachable',
}
}
}
function gatewayStatusLooksRunning(status?: string): boolean {
const normalized = String(status || '').trim().toLowerCase()
if (!normalized || normalized === '—') return false
if (normalized.includes('not running') || normalized === 'stopped' || normalized === 'stop') return false
return normalized.includes('running') || normalized === 'active'
}
async function buildRuntimeStatus(profile: HermesProfile | string, bridgeState?: Awaited<ReturnType<typeof readBridgeWorkers>>) {
const name = typeof profile === 'string' ? profile : profile.name
const bridge = bridgeState || await readBridgeWorkers()
let gateway: { running: boolean; profile: string; error?: string }
if (typeof profile !== 'string' && profile.gatewayStatus !== undefined) {
const profileListRunning = gatewayStatusLooksRunning(profile.gatewayStatus)
if (profileListRunning) {
gateway = {
running: true,
profile: name,
}
} else {
try {
gateway = await getGatewayRuntimeStatusForProfile(name)
} catch (err: any) {
gateway = {
running: false,
profile: name,
error: err?.message || 'Gateway status check failed',
}
}
}
} else {
try {
gateway = await getGatewayRuntimeStatusForProfile(name)
} catch (err: any) {
gateway = {
running: false,
profile: name,
error: err?.message || 'Gateway status check failed',
}
}
}
return {
profile: name,
bridge: {
running: !!bridge.workers[name],
profile: name,
reachable: bridge.reachable,
error: bridge.reachable ? undefined : bridge.error,
},
gateway,
}
}
function setRuntimeStatusCache(status: RuntimeStatus, checkedAt = Date.now()): void {
runtimeStatusCache.set(status.profile, {
status,
updatedAt: checkedAt,
})
}
function listProfilesForStatusFast(): HermesProfile[] {
return filterVisibleProfiles(listProfilesFromDisk(getActiveProfileName()))
}
async function refreshRuntimeStatusCache(checkedAt: number): Promise<void> {
const profiles = await listProfilesForStatus()
const bridge = await readBridgeWorkers()
const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge)))
statuses.forEach(status => setRuntimeStatusCache(status, checkedAt))
}
function startRuntimeStatusRefresh(): void {
const startedAt = Date.now()
runtimeStatusRefreshPromise = refreshRuntimeStatusCache(startedAt)
.catch((err) => {
logger.warn(err, '[profiles] failed to refresh runtime status cache')
})
.finally(() => {
runtimeStatusRefreshPromise = null
if (runtimeStatusMinimumFreshAt > startedAt) {
startRuntimeStatusRefresh()
}
})
}
function scheduleRuntimeStatusRefresh(): void {
runtimeStatusMinimumFreshAt = Math.max(runtimeStatusMinimumFreshAt, Date.now())
if (runtimeStatusRefreshPromise) return
startRuntimeStatusRefresh()
}
export async function list(ctx: any) {
try {
let profiles: HermesProfile[]
try {
profiles = await hermesCli.listProfiles()
} catch (err: any) {
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
const activeProfileName = getActiveProfileName()
if (!isForbiddenProfileName(activeProfileName)) throw err
logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName)
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
profiles = listProfilesFromDisk('default')
}
const activeProfileName = requestedProfileName(ctx)
profiles = filterVisibleProfiles(profiles)
profiles = filterProfilesForUser(ctx, profiles)
// Web UI active profile is request-scoped and comes from X-Hermes-Profile.
profiles.forEach(p => {
p.active = (p.name === activeProfileName)
})
ctx.body = { profiles: attachProfileAvatars(profiles) }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function create(ctx: any) {
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` }
return
}
try {
const output = await hermesCli.createProfile(name, clone)
// clone=true 时执行智能清理:
// - 删除 .env 中的独占平台凭据(Weixin / Telegram / Slack / ...
// - 禁用 config.yaml 中对应的平台节点
// 避免新 profile 与源 profile 共享同一个 bot token 导致互斥冲突。
let strippedCredentials: string[] = []
let disabledPlatforms: string[] = []
let strippedConfigCredentials: string[] = []
if (clone) {
try {
const cleanup = smartCloneCleanup(name)
strippedCredentials = cleanup.strippedCredentials
disabledPlatforms = cleanup.disabledPlatforms
strippedConfigCredentials = cleanup.strippedConfigCredentials
if (
strippedCredentials.length > 0 ||
disabledPlatforms.length > 0 ||
strippedConfigCredentials.length > 0
) {
logger.info(
'Smart clone cleanup for "%s": stripped %d env credentials (%s), disabled %d platforms (%s), stripped %d config credentials (%s)',
name,
strippedCredentials.length, strippedCredentials.join(','),
disabledPlatforms.length, disabledPlatforms.join(','),
strippedConfigCredentials.length, strippedConfigCredentials.join(','),
)
}
} catch (err: any) {
// 清理失败不应阻断 profile 创建,仅记日志
logger.error(err, 'Smart clone cleanup failed for "%s"', name)
}
}
await injectBundledSkillsForProfile(name)
ctx.body = {
success: true,
message: output.trim(),
strippedCredentials,
disabledPlatforms,
strippedConfigCredentials,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function get(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
try {
const profile = await hermesCli.getProfile(name)
ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function updateAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
const body = ctx.request.body as { type?: string; seed?: string; dataUrl?: string }
try {
const dir = profileMetadataDir(name)
await mkdir(dir, { recursive: true })
const updatedAt = Date.now()
if (body.type === 'generated') {
const seed = String(body.seed || name).trim() || name
const meta: ProfileAvatarMeta = { type: 'generated', seed, updatedAt }
rmSync(profileAvatarImagePath(name), { force: true })
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
ctx.body = { avatar: readProfileAvatar(name) }
return
}
if (body.type === 'image' && typeof body.dataUrl === 'string') {
const { mime, buffer } = parseAvatarDataUrl(body.dataUrl)
const meta: ProfileAvatarMeta = { type: 'image', file: 'avatar.bin', mime, updatedAt }
await writeFile(profileAvatarImagePath(name), buffer, { mode: 0o600 })
await writeFile(profileAvatarMetaPath(name), JSON.stringify(meta, null, 2) + '\n', { mode: 0o600 })
ctx.body = { avatar: readProfileAvatar(name) }
return
}
ctx.status = 400
ctx.body = { error: 'Invalid avatar payload' }
} catch (err: any) {
ctx.status = 400
ctx.body = { error: err.message }
}
}
export async function deleteAvatar(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
try {
removeProfileMetadata(name)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function runtimeStatus(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const profiles = await listProfilesForStatus()
const profile = profiles.find(item => item.name === name)
const status = await buildRuntimeStatus(profile || name)
setRuntimeStatusCache(status)
ctx.body = status
} catch {
const status = await buildRuntimeStatus(name)
setRuntimeStatusCache(status)
ctx.body = status
}
}
export async function runtimeStatuses(ctx: any) {
try {
const refreshParam = ctx.query?.refresh
const refreshRequested = refreshParam === undefined || (refreshParam !== '0' && refreshParam !== 'false')
if (refreshRequested) scheduleRuntimeStatusRefresh()
const profiles = filterProfilesForUser(ctx, listProfilesForStatusFast())
const statuses: RuntimeStatus[] = []
profiles.forEach(profile => {
const cached = runtimeStatusCache.get(profile.name)
if (cached && cached.updatedAt >= runtimeStatusMinimumFreshAt) {
statuses.push(cached.status)
}
})
ctx.body = {
profiles: statuses,
refreshing: !!runtimeStatusRefreshPromise,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
async function listProfilesForStatus(): Promise<HermesProfile[]> {
let profiles: HermesProfile[]
try {
profiles = await hermesCli.listProfiles()
} catch {
profiles = listProfilesFromDisk(getActiveProfileName())
}
return filterVisibleProfiles(profiles)
}
export async function restartGatewayForProfile(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const gateway = await restartGatewayRuntimeForProfile(name)
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', name, result.destroyed)
const cached = runtimeStatusCache.get(name)?.status
if (cached) {
setRuntimeStatusCache({
...cached,
bridge: {
...cached.bridge,
running: false,
},
gateway,
})
}
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions after gateway restart profile=%s', name)
}
ctx.body = { success: true, gateway }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function restartProfileRuntime(ctx: any) {
const name = String(ctx.params.name || '').trim() || 'default'
if (denyProfile(ctx, name)) return
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved` }
return
}
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions after profile restart profile=%s destroyed=%s', name, result.destroyed)
const profiles = await listProfilesForStatus()
const profile = profiles.find(item => item.name === name)
const status = await buildRuntimeStatus(profile || name)
setRuntimeStatusCache(status)
ctx.body = {
success: true,
destroyed: result.destroyed,
status,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const { name } = ctx.params
if (denyProfile(ctx, name)) return
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
return
}
try {
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name)
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
removeProfileMetadata(name)
ctx.body = { success: true }
} else if (deleteForbiddenProfileFromDisk(name)) {
removeProfileMetadata(name)
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to delete profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function rename(ctx: any) {
if (denyProfile(ctx, ctx.params.name)) return
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
ctx.body = { error: 'Missing new_name' }
return
}
try {
const ok = await hermesCli.renameProfile(ctx.params.name, new_name)
if (ok) {
renameProfileMetadata(ctx.params.name, new_name)
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to rename profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function switchProfile(ctx: any) {
const { name } = ctx.request.body as { name?: string }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` }
return
}
try {
if (denyProfile(ctx, name)) return
const output = await useProfileWithFallback(name)
const actualActive = getActiveProfileName()
if (actualActive !== name) {
ctx.status = 500
ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` }
return
}
try {
const result = await bridgeCleanupClient().destroyProfile(name)
logger.info('[switchProfile] destroyed bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed)
} catch (err: any) {
logger.warn(err, '[switchProfile] failed to destroy bridge sessions for profile "%s"', name)
}
try {
const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path)
const profileConfig = join(detail.path, 'config.yaml')
if (!existsSync(profileConfig)) {
writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8')
logger.info('Created config.yaml for: %s', detail.path)
}
const profileEnv = join(detail.path, '.env')
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
logger.info('Created .env for: %s', detail.path)
}
} catch (err: any) {
logger.error(err, 'Ensure config failed')
}
await injectBundledSkillsForProfile(name)
SessionDeleter.getInstance().switchProfile(name)
logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name)
ctx.body = {
success: true,
message: output.trim(),
active: name,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function exportProfile(ctx: any) {
const { name } = ctx.params
if (denyProfile(ctx, name)) return
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
try {
await hermesCli.exportProfile(name, outputPath)
if (!existsSync(outputPath)) {
ctx.status = 500
ctx.body = { error: 'Export file not found' }
return
}
const filename = basename(outputPath)
ctx.set('Content-Disposition', `attachment; filename="${filename}"`)
ctx.set('Content-Type', 'application/gzip')
ctx.body = createReadStream(outputPath)
ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function importProfile(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
const tmpDir = join(tmpdir(), 'hermes-import')
await mkdir(tmpDir, { recursive: true })
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
let archivePath = ''
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
archivePath = join(tmpDir, filename)
await writeFile(archivePath, Buffer.from(data, 'binary'))
break
}
if (!archivePath) {
ctx.status = 400
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
return
}
try {
const result = await hermesCli.importProfile(archivePath)
try { unlinkSync(archivePath) } catch { }
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
try { unlinkSync(archivePath) } catch { }
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,247 @@
import { existsSync, readFileSync } from 'fs'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { updateConfigYamlForProfile, saveEnvValueForProfile, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { PROVIDER_PRESETS } from '../../shared/providers'
import { logger } from '../../services/logger'
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth', 'openai-codex'])
const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth', 'openai-codex'])
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
async function clearStoredAuthProvider(profile: string, poolKey: string) {
try {
const authPath = authPathForProfile(profile)
if (!existsSync(authPath)) return
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
let changed = false
if (auth.providers && Object.prototype.hasOwnProperty.call(auth.providers, poolKey)) {
delete auth.providers[poolKey]
changed = true
}
if (auth.credential_pool && Object.prototype.hasOwnProperty.call(auth.credential_pool, poolKey)) {
delete auth.credential_pool[poolKey]
changed = true
}
if (changed) {
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
}
} catch (err: any) { logger.error(err, 'Failed to clear auth credentials for %s', poolKey) }
}
function buildProviderEntry(name: string, base_url: string, api_key: string, model: string, context_length?: number) {
const entry: any = { name, base_url, api_key, model }
if (context_length && context_length > 0) {
entry.models = { [model]: { context_length } }
}
return entry
}
function normalizeBaseUrl(url: string): string {
return String(url || '').trim().replace(/\/+$/, '')
}
function builtinBaseUrl(poolKey: string, requestedBaseUrl: string): string {
return requestedBaseUrl || PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || ''
}
function shouldPersistBuiltinBaseUrl(poolKey: string, requestedBaseUrl: string): boolean {
const presetBaseUrl = PROVIDER_PRESETS.find(p => p.value === poolKey)?.base_url || ''
if (!requestedBaseUrl || !presetBaseUrl) return !!requestedBaseUrl
return normalizeBaseUrl(requestedBaseUrl) !== normalizeBaseUrl(presetBaseUrl)
}
export async function create(ctx: any) {
const { name, base_url, api_key, model, context_length, providerKey } = ctx.request.body as {
name: string; base_url: string; api_key: string; model: string; context_length?: number; providerKey?: string | null
}
const normalizedName = String(name || '').trim()
const poolKey = providerKey || `custom:${normalizedName.toLowerCase().replace(/ /g, '-')}`
const isBuiltin = poolKey in PROVIDER_ENV_MAP
const effectiveBaseUrl = isBuiltin ? builtinBaseUrl(poolKey, base_url) : base_url
if (!normalizedName || !effectiveBaseUrl || !model) {
ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return
}
if (!api_key && !OPTIONAL_API_KEY_PROVIDERS.has(String(providerKey || ''))) {
ctx.status = 400; ctx.body = { error: 'Missing API key' }; return
}
try {
const profile = requestedProfile(ctx)
await updateConfigYamlForProfile(profile, async (config) => {
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
if (!isBuiltin) {
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
const existing = (config.custom_providers as any[]).find(
(e: any) => `custom:${e.name}` === poolKey
)
if (existing) {
existing.base_url = effectiveBaseUrl
existing.api_key = api_key
existing.model = model
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', ''))
if (preset?.api_mode) existing.api_mode = preset.api_mode
if (context_length && context_length > 0) {
if (!existing.models) existing.models = {}
existing.models[model] = existing.models[model] || {}
existing.models[model].context_length = context_length
}
} else {
const entry = buildProviderEntry(normalizedName.toLowerCase().replace(/ /g, '-'), effectiveBaseUrl, api_key, model, context_length)
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey.replace('custom:', ''))
if (preset?.api_mode) entry.api_mode = preset.api_mode
config.custom_providers.push(entry)
}
config.model.default = model
config.model.provider = poolKey
} else {
if (PROVIDER_ENV_MAP[poolKey].api_key_env) {
await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].api_key_env, api_key)
if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) }
config.model.default = model
config.model.provider = poolKey
} else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) {
if (PROVIDER_ENV_MAP[poolKey].base_url_env && shouldPersistBuiltinBaseUrl(poolKey, base_url)) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, effectiveBaseUrl) }
config.model.default = model
config.model.provider = poolKey
} else {
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
const existing = (config.custom_providers as any[]).find(
(e: any) => `custom:${e.name}` === `custom:${poolKey}`
)
if (existing) {
existing.base_url = effectiveBaseUrl
existing.api_key = api_key
existing.model = model
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
if (preset?.api_mode) existing.api_mode = preset.api_mode
if (context_length && context_length > 0) {
if (!existing.models) existing.models = {}
existing.models[model] = existing.models[model] || {}
existing.models[model].context_length = context_length
}
} else {
const entry = buildProviderEntry(poolKey, effectiveBaseUrl, api_key, model, context_length)
const preset = PROVIDER_PRESETS.find(p => p.value === poolKey)
if (preset?.api_mode) entry.api_mode = preset.api_mode
config.custom_providers.push(entry)
}
config.model.default = model
config.model.provider = `custom:${poolKey}`
}
}
delete config.model.base_url
delete config.model.api_key
return config
})
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function update(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
const { name, base_url, api_key, model } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string
}
try {
const profile = requestedProfile(ctx)
const isCustom = poolKey.startsWith('custom:')
if (isCustom) {
const found = await updateConfigYamlForProfile(profile, (config) => {
if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false }
const entry = (config.custom_providers as any[]).find((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
if (!entry) return { data: config, result: false, write: false }
if (name !== undefined) entry.name = name
if (base_url !== undefined) entry.base_url = base_url
if (api_key !== undefined) entry.api_key = api_key
if (model !== undefined) entry.model = model
return { data: config, result: true }
})
if (!found) {
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
}
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (!envMapping?.api_key_env) {
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
}
if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) }
}
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
try {
const profile = requestedProfile(ctx)
const isCustom = poolKey.startsWith('custom:')
const removed = await updateConfigYamlForProfile(profile, async (config) => {
if (isCustom) {
const idx = Array.isArray(config.custom_providers)
? (config.custom_providers as any[]).findIndex((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
: -1
if (idx === -1) return { data: config, result: false, write: false }
;(config.custom_providers as any[]).splice(idx, 1)
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (envMapping?.api_key_env) {
await saveEnvValueForProfile(profile, envMapping.api_key_env, '')
}
if (envMapping?.base_url_env) {
await saveEnvValueForProfile(profile, envMapping.base_url_env, '')
}
}
if (config.model?.provider === poolKey) {
const remaining = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
if (remaining.length > 0) {
const fallbackCp = remaining[0]
const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}`
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
config.model.default = fallbackCp.model
config.model.provider = fallbackKey
delete config.model.base_url
delete config.model.api_key
} else {
config.model = {}
}
}
return { data: config, result: true }
})
if (!removed) {
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
}
if (!isCustom) {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (!envMapping) {
ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return
}
}
await clearStoredAuthProvider(profile, poolKey)
// TODO: Test if provider works without gateway restart
// try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,911 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getSessionDetailPaginatedFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import {
listSessions as localListSessions,
searchSessions as localSearchSessions,
getSession as localGetSession,
getSessionDetail as localGetSessionDetail,
deleteSession as localDeleteSession,
renameSession as localRenameSession,
createSession as localCreateSession,
addMessages as localAddMessages,
updateSession as localUpdateSession,
updateSessionStats as localUpdateSessionStats,
} from '../../db/hermes/session-store'
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context'
import { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getGroupChatServer } from '../../routes/hermes/group-chat'
import { logger } from '../../services/logger'
import type { ConversationSummary } from '../../services/hermes/conversations'
import { listUserProfiles } from '../../db/hermes/users-store'
import { readConfigYamlForProfile } from '../../services/config-helpers'
function getPendingDeletedSessionIds(): Set<string> {
return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set<string>()
}
function filterPendingDeletedSessions<T extends { id: string }>(items: T[]): T[] {
const pendingIds = getPendingDeletedSessionIds()
if (pendingIds.size === 0) return items
return items.filter(item => !pendingIds.has(item.id))
}
function filterPendingDeletedConversationSummaries(items: ConversationSummary[]): ConversationSummary[] {
return filterPendingDeletedSessions(items)
}
function requestedProfile(ctx: any): string | undefined {
const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '')
return value || undefined
}
function explicitProfileFilter(ctx: any): string | undefined {
const value = typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : ''
return value || undefined
}
function allowedProfileSet(ctx: any): Set<string> | null {
const user = ctx.state?.user
if (!user || user.role === 'super_admin') return null
return new Set(listUserProfiles(user.id).map(profile => profile.profile_name))
}
function canAccessProfile(ctx: any, profile: string | null | undefined): boolean {
const allowed = allowedProfileSet(ctx)
return !allowed || allowed.has(profile || 'default')
}
function filterByAllowedProfiles<T>(ctx: any, items: T[]): T[] {
const allowed = allowedProfileSet(ctx)
if (!allowed) return items
return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default'))
}
function denySessionAccess(ctx: any, session: any | null | undefined): boolean {
if (!session || canAccessProfile(ctx, session.profile)) return false
ctx.status = 403
ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` }
return true
}
interface HermesDeleteResult {
attempted: boolean
deleted: boolean
profile?: string
error?: string
}
interface BatchDeleteTarget {
id: string
profile?: string | null
}
interface ProfileDefaultModel {
model: string
provider: string
}
interface LocalImportMessage {
session_id: string
role: string
content: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
timestamp?: number
token_count?: number | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
}
function hasProfileOnDisk(profile: string): boolean {
return listProfileNamesFromDisk().includes(profile || 'default')
}
async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise<HermesDeleteResult> {
const targetProfile = profile || 'default'
if (!hasProfileOnDisk(targetProfile)) {
return { attempted: false, deleted: false, profile: targetProfile }
}
try {
const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile)
if (!hermesSession) {
return { attempted: false, deleted: false, profile: targetProfile }
}
const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile)
return {
attempted: true,
deleted,
profile: targetProfile,
error: deleted ? undefined : 'Failed to delete Hermes session',
}
} catch (err: any) {
const message = err?.message || 'Failed to inspect Hermes session'
logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped')
return { attempted: true, deleted: false, profile: targetProfile, error: message }
}
}
async function getProfileDefaultModel(profile: string): Promise<ProfileDefaultModel> {
try {
const config = await readConfigYamlForProfile(profile)
const modelSection = config?.model
if (modelSection && typeof modelSection === 'object' && !Array.isArray(modelSection)) {
return {
model: String(modelSection.default || '').trim(),
provider: String(modelSection.provider || '').trim(),
}
}
if (typeof modelSection === 'string') {
return { model: modelSection.trim(), provider: '' }
}
} catch (err) {
logger.warn({ err, profile }, 'Hermes Session: failed to read profile default model for import')
}
return { model: '', provider: '' }
}
function normalizeImportText(value: unknown): string {
if (value == null) return ''
if (typeof value === 'string') return value
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function normalizeImportNullableText(value: unknown): string | null {
const text = normalizeImportText(value)
return text ? text : null
}
function normalizeImportToolCalls(value: unknown): any[] | null {
if (!Array.isArray(value)) return null
const calls = value
.map((call: any) => {
const id = String(call?.id || '').trim()
const fn = call?.function && typeof call.function === 'object' ? call.function : {}
const name = String(fn.name || call?.name || '').trim()
if (!id || !name) return null
const rawArgs = fn.arguments ?? call?.arguments ?? {}
const args = typeof rawArgs === 'string' ? rawArgs : normalizeImportText(rawArgs || {})
return {
id,
type: String(call?.type || 'function'),
function: { name, arguments: args || '{}' },
}
})
.filter((call): call is { id: string; type: string; function: { name: string; arguments: string } } => Boolean(call))
return calls.length > 0 ? calls : null
}
function buildImportMessages(sessionId: string, messages: any[]): LocalImportMessage[] {
const result: LocalImportMessage[] = []
const knownToolCallIds = new Set<string>()
for (const message of messages) {
const role = String(message?.role || '').trim()
if (role !== 'user' && role !== 'assistant' && role !== 'tool') continue
const toolCalls = role === 'assistant' ? normalizeImportToolCalls(message.tool_calls) : null
if (toolCalls) {
for (const call of toolCalls) knownToolCallIds.add(call.id)
}
if (role === 'tool') {
const callId = String(message?.tool_call_id || '').trim()
if (!callId || !knownToolCallIds.has(callId)) continue
result.push({
session_id: sessionId,
role,
content: normalizeImportText(message?.content),
tool_call_id: callId,
tool_calls: null,
tool_name: normalizeImportNullableText(message?.tool_name),
timestamp: Number(message?.timestamp || 0),
token_count: message?.token_count == null ? null : Number(message.token_count),
finish_reason: normalizeImportNullableText(message?.finish_reason),
reasoning: null,
reasoning_details: null,
reasoning_content: null,
})
continue
}
const content = normalizeImportText(message?.content)
if (role === 'assistant' && !content.trim() && !toolCalls) continue
result.push({
session_id: sessionId,
role,
content,
tool_call_id: null,
tool_calls: toolCalls,
tool_name: null,
timestamp: Number(message?.timestamp || 0),
token_count: message?.token_count == null ? null : Number(message.token_count),
finish_reason: normalizeImportNullableText(message?.finish_reason),
reasoning: role === 'assistant' ? normalizeImportNullableText(message?.reasoning) : null,
reasoning_details: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_details) : null,
reasoning_content: role === 'assistant' ? normalizeImportNullableText(message?.reasoning_content) : null,
})
}
return result
}
export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = explicitProfileFilter(ctx)
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
const summaries: ConversationSummary[] = sessions.map(s => ({
id: s.id,
profile: s.profile || null,
source: s.source,
model: s.model,
provider: s.provider,
title: s.title,
started_at: s.started_at,
ended_at: s.ended_at,
last_active: s.last_active,
message_count: s.message_count,
tool_call_count: s.tool_call_count,
input_tokens: s.input_tokens,
output_tokens: s.output_tokens,
cache_read_tokens: s.cache_read_tokens,
cache_write_tokens: s.cache_write_tokens,
reasoning_tokens: s.reasoning_tokens,
billing_provider: s.billing_provider,
estimated_cost_usd: s.estimated_cost_usd,
actual_cost_usd: s.actual_cost_usd,
cost_status: s.cost_status,
preview: s.preview,
workspace: s.workspace || null,
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
thread_session_count: 1,
}))
ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) }
}
export async function getConversationMessages(ctx: any) {
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
const detail = localGetSessionDetail(ctx.params.id)
if (!detail) {
ctx.status = 404
ctx.body = { error: 'Conversation not found' }
return
}
if (denySessionAccess(ctx, detail)) return
const messages = detail.messages
.filter(m => {
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
if (!m.content) return false
return true
})
.map(m => ({
id: m.id,
session_id: m.session_id,
role: m.role as 'user' | 'assistant',
content: m.content,
timestamp: m.timestamp,
}))
ctx.body = {
session_id: ctx.params.id,
messages,
visible_count: messages.length,
thread_session_count: 1,
}
}
export async function list(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = explicitProfileFilter(ctx)
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = localListSessions(profile, source, effectiveLimit)
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s =>
(s.source === 'api_server' || s.source === 'cli') &&
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
)),
}
}
/**
* List Hermes sessions only (exclude api_server source)
* GET /api/hermes/sessions/hermes?source=&limit=
*/
export async function listHermesSessions(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = requestedProfile(ctx)
const effectiveLimit = limit && limit > 0 ? limit : 2000
const importedIds = new Set(localListSessions(profile, undefined, effectiveLimit).map(session => session.id))
const allSessions = (await listSessionSummaries(source, effectiveLimit, profile))
.map(session => ({
...(profile ? { ...session, profile } : session),
webui_imported: importedIds.has(session.id),
}))
ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) }
}
export async function search(ctx: any) {
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
const profile = explicitProfileFilter(ctx)
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s =>
!knownProfiles || knownProfiles.has(s.profile || 'default'),
)),
}
}
export async function get(ctx: any) {
const session = localGetSessionDetail(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
ctx.body = { session }
}
/**
* Get Hermes session detail only (exclude api_server source)
* GET /api/hermes/sessions/hermes/:id
*/
export async function getHermesSession(ctx: any) {
const profile = requestedProfile(ctx)
// Prefer the Web UI local session store. Hermes state.db can lag behind or
// miss messages for Bridge-backed runs, while the local store is the source
// used by chat rendering and compression.
const localSession = localGetSessionDetail(ctx.params.id)
const localSessionProfile = (localSession?.profile || 'default') as string
if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) {
if (denySessionAccess(ctx, localSession)) return
ctx.body = { session: localSession }
return
}
// Try Hermes state.db next (consistent with listHermesSessions)
try {
const session = profile
? await getSessionDetailFromDbWithProfile(ctx.params.id, profile)
: await getSessionDetailFromDb(ctx.params.id)
if (session && session.source !== 'api_server') {
const sessionWithProfile = profile ? { ...session, profile } : session
if (denySessionAccess(ctx, sessionWithProfile)) return
ctx.body = { session: sessionWithProfile }
return
}
} catch (err) {
logger.warn(err, 'Hermes Session DB: detail query failed, falling back to CLI')
}
// Fallback to CLI
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
// Filter out api_server sessions
if (session.source === 'api_server') {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
ctx.body = { session }
}
export async function importHermesSession(ctx: any) {
const sessionId = ctx.params.id
const profile = requestedProfile(ctx) || getActiveProfileName()
if (!canAccessProfile(ctx, profile)) {
ctx.status = 403
ctx.body = { error: `Profile "${profile || 'default'}" is not available for this user` }
return
}
const existing = localGetSessionDetail(sessionId)
if (existing) {
ctx.body = { ok: true, imported: false, session: existing }
return
}
let detail
try {
detail = await getSessionDetailFromDbWithProfile(sessionId, profile)
} catch (err) {
logger.warn({ err, sessionId, profile }, 'Hermes Session: import query failed')
ctx.status = 500
ctx.body = { error: 'Failed to read Hermes session' }
return
}
if (!detail || detail.source === 'api_server') {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
const profileDefault = await getProfileDefaultModel(profile)
const importTimestamp = Math.floor(Date.now() / 1000)
localCreateSession({
id: detail.id,
profile,
source: 'cli',
model: profileDefault.model,
provider: profileDefault.provider,
title: detail.title || undefined,
})
localUpdateSession(detail.id, {
source: 'cli',
user_id: detail.user_id,
model: profileDefault.model,
provider: profileDefault.provider,
title: detail.title,
started_at: detail.started_at,
ended_at: detail.ended_at,
end_reason: detail.end_reason,
message_count: detail.message_count,
tool_call_count: detail.tool_call_count,
input_tokens: detail.input_tokens,
output_tokens: detail.output_tokens,
cache_read_tokens: detail.cache_read_tokens,
cache_write_tokens: detail.cache_write_tokens,
reasoning_tokens: detail.reasoning_tokens,
billing_provider: detail.billing_provider,
estimated_cost_usd: detail.estimated_cost_usd,
actual_cost_usd: detail.actual_cost_usd,
cost_status: detail.cost_status,
preview: detail.preview,
last_active: importTimestamp,
})
const importMessages = buildImportMessages(detail.id, Array.isArray(detail.messages) ? detail.messages : [])
localAddMessages(importMessages)
localUpdateSessionStats(detail.id)
localUpdateSession(detail.id, {
tool_call_count: detail.tool_call_count,
input_tokens: detail.input_tokens,
output_tokens: detail.output_tokens,
cache_read_tokens: detail.cache_read_tokens,
cache_write_tokens: detail.cache_write_tokens,
reasoning_tokens: detail.reasoning_tokens,
billing_provider: detail.billing_provider,
estimated_cost_usd: detail.estimated_cost_usd,
actual_cost_usd: detail.actual_cost_usd,
cost_status: detail.cost_status,
last_active: importTimestamp,
ended_at: detail.ended_at,
})
ctx.body = { ok: true, imported: true, session: localGetSessionDetail(detail.id) }
}
export async function remove(ctx: any) {
const sessionId = ctx.params.id
const existing = localGetSession(sessionId)
if (denySessionAccess(ctx, existing)) return
const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName()
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
const localDeleted = existing ? localDeleteSession(sessionId) : true
if (!localDeleted) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
deleteUsage(sessionId)
ctx.body = { ok: true, deleted: Boolean(existing), hermes }
}
export async function batchRemove(ctx: any) {
const { ids, sessions } = ctx.request.body as { ids?: string[]; sessions?: BatchDeleteTarget[] }
const rawTargets = Array.isArray(sessions) && sessions.length > 0 ? sessions : ids
if (!rawTargets || !Array.isArray(rawTargets) || rawTargets.length === 0) {
ctx.status = 400
ctx.body = { error: 'ids is required and must be a non-empty array' }
return
}
const targets = rawTargets
.map((target): BatchDeleteTarget | null => {
if (typeof target === 'string') {
const id = target.trim()
return id ? { id } : null
}
if (!target || typeof target.id !== 'string') return null
const id = target.id.trim()
if (!id) return null
const profile = typeof target.profile === 'string' && target.profile.trim()
? target.profile.trim()
: undefined
return { id, profile }
})
.filter((target): target is BatchDeleteTarget => Boolean(target))
if (targets.length === 0) {
ctx.status = 400
ctx.body = { error: 'No valid session ids provided' }
return
}
const results = {
deleted: 0,
failed: 0,
hermesDeleted: 0,
hermesFailed: 0,
errors: [] as Array<{ id: string; error: string }>,
hermesErrors: [] as Array<{ id: string; profile?: string; error: string }>
}
for (const target of targets) {
const { id } = target
const existing = localGetSession(id)
const targetProfile = target.profile || existing?.profile
if (targetProfile && !canAccessProfile(ctx, targetProfile)) {
results.failed++
results.errors.push({ id, error: `Profile "${targetProfile || 'default'}" is not available for this user` })
continue
}
if (!targetProfile && existing && !canAccessProfile(ctx, existing.profile)) {
results.failed++
results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` })
continue
}
const hermes = await deleteHermesSessionIfPresent(id, targetProfile)
if (hermes.deleted) {
results.hermesDeleted++
} else if (hermes.attempted && hermes.error) {
results.hermesFailed++
results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error })
}
const shouldDeleteLocal = Boolean(existing && (!targetProfile || existing.profile === targetProfile))
if (shouldDeleteLocal) {
const ok = localDeleteSession(id)
if (ok) {
deleteUsage(id)
results.deleted++
} else {
results.failed++
results.errors.push({ id, error: 'Failed to delete session' })
}
} else if (hermes.deleted) {
results.deleted++
} else {
results.failed++
results.errors.push({ id, error: 'Session not found' })
}
}
ctx.body = { ...results, ok: true }
}
export async function usageBatch(ctx: any) {
const ids = (ctx.query.ids as string)
if (!ids) {
ctx.body = {}
return
}
const idList = ids.split(',').filter(Boolean)
ctx.body = getUsageBatch(idList)
}
export async function usageSingle(ctx: any) {
const session = localGetSession(ctx.params.id)
if (denySessionAccess(ctx, session)) return
const result = getUsage(ctx.params.id)
if (!result) {
ctx.body = { input_tokens: 0, output_tokens: 0 }
return
}
ctx.body = result
}
export async function rename(ctx: any) {
const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const existing = localGetSession(ctx.params.id)
if (denySessionAccess(ctx, existing)) return
const ok = localRenameSession(ctx.params.id, title.trim())
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to rename session' }
return
}
ctx.body = { ok: true }
}
export async function setWorkspace(ctx: any) {
const { workspace } = ctx.request.body as { workspace?: string }
if (workspace !== undefined && workspace !== null && typeof workspace !== 'string') {
ctx.status = 400
ctx.body = { error: 'workspace must be a string or null' }
return
}
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
const id = ctx.params.id
const existing = getSession(id)
if (denySessionAccess(ctx, existing)) return
if (!existing) {
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
}
updateSession(id, { workspace: workspace || null } as any)
ctx.body = { ok: true }
}
export async function setModel(ctx: any) {
const { model, provider } = ctx.request.body as { model?: string; provider?: string }
if (!model || typeof model !== 'string') {
ctx.status = 400
ctx.body = { error: 'model is required' }
return
}
if (provider !== undefined && provider !== null && typeof provider !== 'string') {
ctx.status = 400
ctx.body = { error: 'provider must be a string' }
return
}
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
const id = ctx.params.id
const existing = getSession(id)
if (denySessionAccess(ctx, existing)) return
if (!existing) {
createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' })
}
updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any)
ctx.body = { ok: true }
}
export async function contextLength(ctx: any) {
const profile = requestedProfile(ctx)
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
}
export async function usageStats(ctx: any) {
const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10)
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30
const profile = requestedProfile(ctx)
let hermes = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
sessions: 0,
by_model: [] as UsageStatsModelRow[],
by_day: [] as UsageStatsDailyRow[],
cost: 0,
total_api_calls: 0,
}
try {
hermes = profile ? await getUsageStatsFromDb(days, undefined, profile) : await getUsageStatsFromDb(days)
} catch (err) {
logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db')
}
const dayMap = new Map<string, UsageStatsDailyRow>()
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
const key = d.toISOString().slice(0, 10)
dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 })
}
for (const d of hermes.by_day) {
const existing = dayMap.get(d.date)
if (existing) {
existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens
existing.cache_read_tokens += d.cache_read_tokens; existing.cache_write_tokens += d.cache_write_tokens
existing.sessions += d.sessions; existing.errors += d.errors; existing.cost += d.cost
}
}
ctx.body = {
total_input_tokens: hermes.input_tokens,
total_output_tokens: hermes.output_tokens,
total_cache_read_tokens: hermes.cache_read_tokens,
total_cache_write_tokens: hermes.cache_write_tokens,
total_reasoning_tokens: hermes.reasoning_tokens,
total_sessions: hermes.sessions,
total_cost: hermes.cost,
total_api_calls: hermes.total_api_calls,
period_days: days,
model_usage: hermes.by_model.sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
daily_usage: [...dayMap.values()],
}
}
/**
* List folders under workspace base path for folder picker.
* GET /api/hermes/workspace/folders?path=<relative_path>
* Base: /opt/data/workspace (overridable via WORKSPACE_BASE env)
*/
export async function listWorkspaceFolders(ctx: any) {
const { resolve, join } = await import('path')
const { readdir } = await import('fs/promises')
const { existsSync } = await import('fs')
const WORKSPACE_BASE = process.env.WORKSPACE_BASE || '/opt/data/workspace'
const subPath = (ctx.query.path as string) || ''
// Security: prevent path traversal
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
if (!isPathWithin(fullPath, WORKSPACE_BASE)) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
}
if (!existsSync(fullPath)) {
ctx.status = 404
ctx.body = { error: 'Path not found', folders: [] }
return
}
try {
const entries = await readdir(fullPath, { withFileTypes: true })
const folders = entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => ({
name: e.name,
path: subPath ? `${subPath}/${e.name}` : e.name,
fullPath: join(fullPath, e.name),
}))
.sort((a, b) => a.name.localeCompare(b.name))
ctx.body = { base: WORKSPACE_BASE, current: subPath, folders }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
const exportCompressor = new ExportCompressor()
export async function exportSession(ctx: any) {
const session = localGetSessionDetail(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
if (denySessionAccess(ctx, session)) return
const mode = (ctx.query.mode as string) || 'full'
const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json')
const title = session.title || 'session'
const safeName = title.replace(/[^a-zA-Z0-9一-鿿_-]/g, '_').slice(0, 50)
const filename = `${safeName}_${ctx.params.id.slice(0, 8)}.${ext}`
if (mode === 'compressed') {
const result = await compressSession(session)
if (ext === 'json') {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'application/json')
ctx.body = JSON.stringify({ id: session.id, title: session.title, ...result.meta, messages: result.messages }, null, 2)
} else {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'text/plain; charset=utf-8')
ctx.body = serializeAsText(session.title, result.messages)
}
} else {
if (ext === 'txt') {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'text/plain; charset=utf-8')
ctx.body = serializeAsText(session.title, session.messages || [])
} else {
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
ctx.set('Content-Type', 'application/json')
ctx.body = JSON.stringify(session, null, 2)
}
}
}
async function compressSession(session: any) {
const profile = session.profile || getActiveProfileName()
const upstream = ''
const apiKey = undefined
const messages = (session.messages || []).map((m: any) => ({
role: m.role,
content: m.content || '',
tool_calls: m.tool_calls,
tool_call_id: m.tool_call_id,
name: m.tool_name,
reasoning_content: m.reasoning,
}))
return exportCompressor.compress(messages, upstream, apiKey, session.id, {
profile,
model: session.model,
provider: session.provider,
})
}
function serializeAsText(title: string | null, messages: any[]): string {
const lines: string[] = [`# ${title || 'Untitled'}`, '']
for (const msg of messages) {
const role = msg.role || 'unknown'
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
const ts = msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : ''
lines.push(`[${role}]${ts ? ' ' + ts : ''}`)
lines.push(content || '')
lines.push('')
}
return lines.join('\n')
}
export async function getConversationMessagesPaginated(ctx: any) {
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
const profile = requestedProfile(ctx)
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
const localResult = getSessionDetailPaginated(ctx.params.id, offset, limit)
const result = localResult && (!profile || localResult.session.profile === profile)
? localResult
: await getSessionDetailPaginatedFromDbWithProfile(ctx.params.id, profile || 'default', offset, limit)
if (!result) {
ctx.status = 404
ctx.body = { error: 'Conversation not found' }
return
}
const session = { ...result.session, profile: (result.session as any).profile || profile || 'default' }
if (denySessionAccess(ctx, session)) return
ctx.body = {
session: {
id: session.id,
profile: session.profile,
source: session.source,
model: session.model,
title: session.title,
started_at: session.started_at,
ended_at: session.ended_at,
last_active: session.last_active,
message_count: session.message_count,
input_tokens: session.input_tokens,
output_tokens: session.output_tokens,
},
messages: result.messages,
total: result.total,
offset: result.offset,
limit: result.limit,
hasMore: result.hasMore,
}
}
@@ -0,0 +1,548 @@
import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join, resolve } from 'path'
import { createHash } from 'crypto'
import {
readConfigYamlForProfile, updateConfigYamlForProfile,
safeReadFile, extractDescription, listFilesRecursive,
} from '../../services/config-helpers'
import type { SkillSource } from '../../services/config-helpers'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
function requestProfileDir(ctx: any): string {
return getProfileDir(requestedProfile(ctx))
}
function requestSkillsDir(ctx: any): string {
return join(requestProfileDir(ctx), 'skills')
}
function expandConfiguredPath(value: string): string {
const expandedEnv = value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
return process.env[braced || bare] || ''
})
if (expandedEnv === '~') return homedir()
if (expandedEnv.startsWith('~/')) return join(homedir(), expandedEnv.slice(2))
return expandedEnv
}
async function resolveExternalSkillsDirs(config: Record<string, any>, localSkillsDir: string): Promise<string[]> {
const rawDirs = config.skills?.external_dirs
const entries = typeof rawDirs === 'string'
? [rawDirs]
: Array.isArray(rawDirs)
? rawDirs
: []
const localResolved = resolve(localSkillsDir)
const seen = new Set<string>()
const dirs: string[] = []
for (const rawEntry of entries) {
const entry = String(rawEntry || '').trim()
if (!entry) continue
const expanded = expandConfiguredPath(entry)
const resolved = resolve(expanded)
if (resolved === localResolved || seen.has(resolved)) continue
try {
const info = await stat(resolved)
if (!info.isDirectory()) continue
} catch {
continue
}
seen.add(resolved)
dirs.push(resolved)
}
return dirs
}
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
function readBundledManifest(manifestContent: string | null): Map<string, string> {
const map = new Map<string, string>()
if (!manifestContent) return map
for (const line of manifestContent.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const idx = trimmed.indexOf(':')
if (idx === -1) continue
const name = trimmed.slice(0, idx).trim()
const hash = trimmed.slice(idx + 1).trim()
if (name && hash) map.set(name, hash)
}
return map
}
/** Read hub-installed skill names from ~/.hermes/skills/.hub/lock.json */
function readHubInstalledNames(lockContent: string | null): Set<string> {
if (!lockContent) return new Set()
try {
const data = JSON.parse(lockContent)
if (data?.installed && typeof data.installed === 'object') {
return new Set(Object.keys(data.installed))
}
} catch { /* ignore */ }
return new Set()
}
/** Compute md5 hash of all files in a directory (mirrors Hermes _dir_hash), with in-memory cache */
const hashCache = new Map<string, { hash: string; mtime: number }>()
const HASH_CACHE_TTL = 60_000 // 1 minute
async function dirHash(directory: string): Promise<string> {
const cached = hashCache.get(directory)
if (cached && Date.now() - cached.mtime < HASH_CACHE_TTL) return cached.hash
const hasher = createHash('md5')
const files = await listFilesRecursive(directory, '')
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
for (const f of files) {
hasher.update(f.path)
const content = await readFile(join(directory, f.path))
hasher.update(content)
}
const hash = hasher.digest('hex')
hashCache.set(directory, { hash, mtime: Date.now() })
return hash
}
/** Determine the source type of a skill */
function getSkillSource(
dirName: string,
bundledManifest: Map<string, string>,
hubNames: Set<string>,
): SkillSource {
if (bundledManifest.has(dirName)) return 'builtin'
if (hubNames.has(dirName)) return 'hub'
return 'local'
}
/** Read .usage.json as a name→stats map */
interface UsageStats { patch_count: number; use_count: number; view_count: number; pinned: boolean }
function readUsageStats(usageContent: string | null): Map<string, UsageStats> {
const map = new Map<string, UsageStats>()
if (!usageContent) return map
try {
const data = JSON.parse(usageContent)
for (const [name, stats] of Object.entries(data)) {
const s = stats as any
map.set(name, { patch_count: s.patch_count ?? 0, use_count: s.use_count ?? 0, view_count: s.view_count ?? 0, pinned: !!s.pinned })
}
} catch { /* ignore */ }
return map
}
async function findSkillDirByName(rootDir: string, skillName: string): Promise<string | null> {
let entries: import('fs').Dirent[]
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
return null
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const entryPath = join(rootDir, entry.name)
const skillMd = await safeReadFile(join(entryPath, 'SKILL.md'))
if (skillMd !== null) {
if (entry.name === skillName) return entryPath
// This is another skill root. Do not search inside its references/scripts.
continue
}
const found = await findSkillDirByName(entryPath, skillName)
if (found) return found
}
return null
}
async function findSkillDirInRoot(rootDir: string, category: string, skillName: string): Promise<string | null> {
if (category === 'misc') {
const skillDir = join(rootDir, skillName)
const skillMd = await safeReadFile(join(skillDir, 'SKILL.md'))
return skillMd !== null ? skillDir : null
}
return findSkillDirByName(join(rootDir, category), skillName)
}
async function resolveSkillDirFromConfig(
config: Record<string, any>,
localSkillsDir: string,
category: string,
skillName: string,
): Promise<string | null> {
const localSkillDir = await findSkillDirInRoot(localSkillsDir, category, skillName)
if (localSkillDir) return localSkillDir
for (const externalDir of await resolveExternalSkillsDirs(config, localSkillsDir)) {
const externalSkillDir = await findSkillDirInRoot(externalDir, category, skillName)
if (externalSkillDir) return externalSkillDir
}
return null
}
/**
* Scan for skills at different directory depths.
*
* Supports both:
* - Three-level: skills/<category>/<skill-name>/SKILL.md (category is a container)
* - Two-level: skills/<skill-name>/SKILL.md (flat skill under "misc" category)
*
* Categories are identified by having a DESCRIPTION.md at the category level
* or by containing subdirectories with SKILL.md (three-level pattern).
* Skills without a parent category (flat skills) are grouped under the "misc" category.
*/
async function scanSkillsDir(skillsDir: string, bundledManifest: Map<string, string>, hubNames: Set<string>, disabledList: string[], usageStats: Map<string, UsageStats>) {
const allEntries = await readdir(skillsDir, { withFileTypes: true })
const dirNames = allEntries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => e.name)
// Classify directories: categories vs. flat skills
const categoryDirs: { name: string; description: string }[] = []
const flatSkills: { name: string; skillMd: string; source: string }[] = []
for (const dirName of dirNames) {
const catDir = join(skillsDir, dirName)
const hasDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
const hasSkillMd = await safeReadFile(join(catDir, 'SKILL.md'))
const subEntries = await readdir(catDir, { withFileTypes: true })
const subDirs = subEntries.filter(se => se.isDirectory())
// Priority: SKILL.md at top level → flat skill
// DESCRIPTION.md or subdirs (without SKILL.md) → category
if (hasSkillMd) {
// Flat skill: has SKILL.md at the top level (two-level pattern)
// Could also have subdirectories (references/, scripts/, etc.)
flatSkills.push({
name: dirName,
skillMd: hasSkillMd,
source: getSkillSource(dirName, bundledManifest, hubNames),
})
} else if (!!hasDesc || subDirs.length > 0) {
// True category: has DESCRIPTION.md or subdirs, but no SKILL.md at top level
const catDescription = hasDesc ? hasDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
categoryDirs.push({ name: dirName, description: catDescription })
}
}
// Build categories with their nested skills
const categories: any[] = []
for (const cat of categoryDirs) {
const catDir = join(skillsDir, cat.name)
const subEntries = await readdir(catDir, { withFileTypes: true })
const skills: any[] = []
// Recursively collect skills from subdirectories (supports nested sub-categories)
async function collectSkills(dir: string): Promise<any[]> {
const entries = await readdir(dir, { withFileTypes: true })
const results: any[] = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const entryPath = join(dir, entry.name)
const skillMd = await safeReadFile(join(entryPath, 'SKILL.md'))
if (skillMd) {
const source = getSkillSource(entry.name, bundledManifest, hubNames)
let modified = false
if (source === 'builtin') {
const manifestHash = bundledManifest.get(entry.name)
if (manifestHash) {
const currentHash = await dirHash(entryPath)
modified = currentHash !== manifestHash
}
}
const usage = usageStats.get(entry.name)
results.push({
name: entry.name,
description: extractDescription(skillMd),
enabled: !disabledList.includes(entry.name),
source,
modified: modified || undefined,
patchCount: usage?.patch_count,
useCount: usage?.use_count,
viewCount: usage?.view_count,
pinned: usage?.pinned || undefined,
})
} else {
// No SKILL.md — might be a sub-category container, recurse deeper
const subResults = await collectSkills(entryPath)
results.push(...subResults)
}
}
return results
}
skills.push(...await collectSkills(catDir))
if (skills.length > 0) {
categories.push({ name: cat.name, description: cat.description, skills })
}
}
// Group flat skills into a "misc" (雜項) category
if (flatSkills.length > 0) {
const miscSkills: any[] = []
for (const fs of flatSkills) {
const usage = usageStats.get(fs.name)
miscSkills.push({
name: fs.name,
description: extractDescription(fs.skillMd),
enabled: !disabledList.includes(fs.name),
source: fs.source,
modified: undefined,
patchCount: usage?.patch_count,
useCount: usage?.use_count,
viewCount: usage?.view_count,
pinned: usage?.pinned || undefined,
})
}
miscSkills.sort((a: any, b: any) => a.name.localeCompare(b.name))
categories.push({
name: 'misc',
description: '雜項',
skills: miscSkills,
})
}
categories.sort((a, b) => a.name.localeCompare(b.name))
for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) }
return categories
}
async function scanExternalSkillsDir(skillsDir: string, disabledList: string[], usageStats: Map<string, UsageStats>) {
return scanSkillsDir(skillsDir, new Map(), new Set(), disabledList, usageStats).then(categories =>
categories.map(category => ({
...category,
skills: category.skills.map((skill: any) => ({
...skill,
source: 'external' as SkillSource,
modified: undefined,
})),
})),
)
}
function collectSkillNames(categories: any[]): Set<string> {
const names = new Set<string>()
for (const category of categories) {
for (const skill of category.skills || []) {
if (skill?.name) names.add(skill.name)
}
}
return names
}
function mergeExternalCategories(categories: any[], externalCategories: any[]): any[] {
const byName = new Map<string, any>()
for (const category of categories) {
byName.set(category.name, { ...category, skills: [...category.skills] })
}
const seenSkills = collectSkillNames(categories)
for (const externalCategory of externalCategories) {
const target = byName.get(externalCategory.name) || {
name: externalCategory.name,
description: externalCategory.description,
skills: [],
}
for (const skill of externalCategory.skills || []) {
if (seenSkills.has(skill.name)) continue
seenSkills.add(skill.name)
target.skills.push(skill)
}
if (target.skills.length > 0) byName.set(target.name, target)
}
const merged = [...byName.values()]
.filter(category => category.skills.length > 0)
.sort((a, b) => a.name.localeCompare(b.name))
for (const category of merged) {
category.skills.sort((a: any, b: any) => a.name.localeCompare(b.name))
}
return merged
}
export async function list(ctx: any) {
const skillsDir = requestSkillsDir(ctx)
try {
const config = await readConfigYamlForProfile(requestedProfile(ctx))
const disabledList: string[] = config.skills?.disabled || []
// Read provenance sources
const bundledManifest = readBundledManifest(await safeReadFile(join(skillsDir, '.bundled_manifest')))
const hubNames = readHubInstalledNames(await safeReadFile(join(skillsDir, '.hub', 'lock.json')))
const usageStats = readUsageStats(await safeReadFile(join(skillsDir, '.usage.json')))
// Scan all skills (supports both two-level and three-level directory structures)
let categories = await scanSkillsDir(skillsDir, bundledManifest, hubNames, disabledList, usageStats)
for (const externalDir of await resolveExternalSkillsDirs(config, skillsDir)) {
const externalCategories = await scanExternalSkillsDir(externalDir, disabledList, usageStats)
categories = mergeExternalCategories(categories, externalCategories)
}
// Read archived skills from .archive/
const archived: any[] = []
const archiveDir = join(skillsDir, '.archive')
const archiveEntries = await readdir(archiveDir, { withFileTypes: true }).catch(() => [] as import('fs').Dirent[])
for (const entry of archiveEntries) {
if (!entry.isDirectory()) continue
const skillMd = await safeReadFile(join(archiveDir, entry.name, 'SKILL.md'))
if (skillMd) {
const usage = usageStats.get(entry.name)
archived.push({
name: entry.name,
description: extractDescription(skillMd),
source: getSkillSource(entry.name, bundledManifest, hubNames),
patchCount: usage?.patch_count,
useCount: usage?.use_count,
viewCount: usage?.view_count,
pinned: usage?.pinned || undefined,
})
}
}
archived.sort((a: any, b: any) => a.name.localeCompare(b.name))
ctx.body = { categories, archived }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
}
}
export async function usageStats(ctx: any) {
const rawDays = parseInt(String(ctx.query?.days ?? '7'), 10)
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 7
try {
ctx.body = await getSkillUsageStatsFromDb(days, undefined, requestedProfile(ctx))
} catch (err: any) {
ctx.status = 500
ctx.body = { error: `Failed to read skill usage stats: ${err.message}` }
}
}
export async function toggle(ctx: any) {
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
if (!name || typeof enabled !== 'boolean') {
ctx.status = 400
ctx.body = { error: 'Missing name or enabled flag' }
return
}
try {
await updateConfigYamlForProfile(requestedProfile(ctx), (config) => {
if (!config.skills) config.skills = {}
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
const disabled = config.skills.disabled as string[]
const idx = disabled.indexOf(name)
if (enabled) { if (idx !== -1) disabled.splice(idx, 1) }
else { if (idx === -1) disabled.push(name) }
return config
})
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function listFiles(ctx: any) {
const { category, skill } = ctx.params
const profileSkillsDir = requestSkillsDir(ctx)
try {
const config = await readConfigYamlForProfile(requestedProfile(ctx))
const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skill)
if (!skillDir) {
ctx.status = 404
ctx.body = { error: 'Skill not found' }
return
}
const allFiles = await listFilesRecursive(skillDir, '')
const files = allFiles.filter((f: any) => f.path !== 'SKILL.md')
ctx.body = { files }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function readFile_(ctx: any) {
const filePath = (ctx.params as any).path
const profileSkillsDir = requestSkillsDir(ctx)
// Handle 'misc' category: real skill dir is skills/<skill>, not skills/misc/<skill>
let realPath = filePath
if (filePath.startsWith('misc/')) {
realPath = filePath.slice(5)
}
const fullPath = resolve(join(profileSkillsDir, realPath))
if (!isPathWithin(fullPath, profileSkillsDir)) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
}
let content = await safeReadFile(fullPath)
if (content === null) {
// Fallback: recursive search for nested skills (e.g., mlops/lm-evaluation-harness/SKILL.md
// where actual path is mlops/evaluation/lm-evaluation-harness/SKILL.md)
const parts = filePath.split('/')
if (parts.length >= 2) {
const category = parts[0]
const skillName = parts[1]
const restPath = parts.slice(2).join('/')
const config = await readConfigYamlForProfile(requestedProfile(ctx))
const skillDir = await resolveSkillDirFromConfig(config, profileSkillsDir, category, skillName)
if (skillDir) {
const resolvedPath = resolve(join(skillDir, restPath))
if (isPathWithin(resolvedPath, skillDir)) {
const nestedContent = await safeReadFile(resolvedPath)
if (nestedContent !== null) {
ctx.body = { content: nestedContent }
return
}
}
}
}
ctx.status = 404
ctx.body = { error: 'File not found' }
return
}
ctx.body = { content }
}
async function updatePinnedSkill(skillsDir: string, name: string, pinned: boolean): Promise<void> {
await mkdir(skillsDir, { recursive: true })
const usagePath = join(skillsDir, '.usage.json')
let usage: Record<string, any> = {}
const raw = await safeReadFile(usagePath)
if (raw) {
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) usage = parsed
} catch { /* rewrite malformed usage file with the requested pin state */ }
}
const current = usage[name]
usage[name] = current && typeof current === 'object' && !Array.isArray(current)
? { ...current, pinned }
: { patch_count: 0, use_count: 0, view_count: 0, pinned }
await writeFile(usagePath, `${JSON.stringify(usage, null, 2)}\n`, 'utf-8')
}
export async function pin_(ctx: any) {
const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean }
if (!name || typeof pinned !== 'boolean') {
ctx.status = 400
ctx.body = { error: 'Missing name or pinned flag' }
return
}
try {
await updatePinnedSkill(requestSkillsDir(ctx), name, pinned)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,70 @@
import type { Context } from 'koa'
import { textToSpeech, openaiCompatibleTts, speedToEdgeRate } from '../../services/hermes/tts'
export async function generate(ctx: Context) {
const { text, lang } = ctx.request.body as {
text?: string
lang?: string
}
if (!text || typeof text !== 'string') {
ctx.status = 400
ctx.body = { error: 'text is required' }
return
}
if (text.length > 5000) {
ctx.status = 400
ctx.body = { error: 'text is too long (max 5000 characters)' }
return
}
const { audio, engine } = await textToSpeech({ text, lang })
ctx.set('Content-Type', 'audio/mpeg')
ctx.set('Content-Length', String(audio.length))
ctx.set('X-TTS-Engine', engine)
ctx.body = audio
}
/**
* OpenAI-compatible TTS endpoint.
* Accepts: { model, input, voice, speed }
* Returns audio/mpeg stream.
*/
export async function openaiProxy(ctx: Context) {
const body = ctx.request.body as {
input?: string
voice?: string
speed?: number
model?: string
rate?: string
pitch?: string
}
if (!body.input || typeof body.input !== 'string') {
ctx.status = 400
ctx.body = { error: 'input is required' }
return
}
if (body.input.length > 5000) {
ctx.status = 400
ctx.body = { error: 'input is too long (max 5000 characters)' }
return
}
const { audio, engine } = await openaiCompatibleTts({
input: body.input,
voice: body.voice,
speed: body.speed,
model: body.model,
rate: body.rate,
pitch: body.pitch,
})
ctx.set('Content-Type', 'audio/mpeg')
ctx.set('Content-Length', String(audio.length))
ctx.set('X-TTS-Engine', engine)
ctx.body = audio
}
@@ -0,0 +1,55 @@
import axios from 'axios'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart'
import { saveEnvValueForProfile } from '../../services/config-helpers'
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
export async function getQrcode(ctx: any) {
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, { params: { bot_type: 3 }, timeout: 15000 })
const data = res.data
if (!data || !data.qrcode) { ctx.status = 500; ctx.body = { error: 'Failed to get QR code' }; return }
ctx.body = { qrcode: data.qrcode, qrcode_url: data.qrcode_img_content }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message || 'Failed to connect to iLink API' }
}
}
export async function pollStatus(ctx: any) {
const qrcode = ctx.query.qrcode as string
if (!qrcode) { ctx.status = 400; ctx.body = { error: 'Missing qrcode parameter' }; return }
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, { params: { qrcode }, timeout: 35000 })
const data = res.data
const status = data?.status || 'wait'
if (status === 'confirmed') {
ctx.body = { status: 'confirmed', account_id: data.ilink_bot_id, token: data.bot_token, base_url: data.baseurl }
} else {
ctx.body = { status }
}
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message || 'Failed to poll QR status' }
}
}
export async function save(ctx: any) {
const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string }
if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return }
try {
const profile = requestedProfile(ctx)
const entries: Record<string, string> = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token }
if (base_url) entries.WEIXIN_BASE_URL = base_url
for (const [key, val] of Object.entries(entries)) {
await saveEnvValueForProfile(profile, key, val)
}
await restartGatewayForProfile(profile)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,369 @@
import { createHash, randomBytes, randomUUID } from 'crypto'
import { createServer, type Server } from 'http'
import { request as httpsRequest, type RequestOptions } from 'https'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { URL } from 'url'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
import { logger } from '../../services/logger'
import { updateConfigYamlForProfile } from '../../services/config-helpers'
const XAI_OAUTH_ISSUER = 'https://auth.x.ai'
const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`
const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
const XAI_REDIRECT_HOST = '127.0.0.1'
const XAI_CALLBACK_BIND_HOST = process.env.HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST?.trim() || XAI_REDIRECT_HOST
const XAI_REDIRECT_PORT = 56121
const XAI_REDIRECT_PATH = '/callback'
const POLL_MAX_DURATION = 15 * 60 * 1000
const XAI_DEFAULT_MODEL = 'grok-4.3'
interface XaiSession {
id: string
profile: string
status: 'pending' | 'approved' | 'expired' | 'error'
authorizeUrl: string
redirectUri: string
codeVerifier: string
state: string
tokenEndpoint: string
discovery: Record<string, string>
server: Server
error?: string
createdAt: number
}
interface AuthJson {
version?: number
active_provider?: string
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
updated_at?: string
}
const sessions = new Map<string, XaiSession>()
export function applyXaiOAuthDefaultModel(config: Record<string, any>): Record<string, any> {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
const currentDefault = String(config.model.default || '').trim()
config.model.provider = 'xai-oauth'
config.model.default = currentDefault.toLowerCase().startsWith('grok-')
? currentDefault
: XAI_DEFAULT_MODEL
delete config.model.base_url
delete config.model.api_key
return config
}
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => {
if (now - session.createdAt > POLL_MAX_DURATION + 60000) {
closeServer(session)
sessions.delete(id)
}
})
}
function closeServer(session: XaiSession) {
try { session.server.close() } catch {}
}
function base64Url(input: Buffer): string {
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function makeCodeVerifier(): string {
return base64Url(randomBytes(48))
}
function makeCodeChallenge(verifier: string): string {
return base64Url(createHash('sha256').update(verifier).digest())
}
function validateXaiEndpoint(raw: string, field: string): string {
const url = new URL(raw)
if (url.protocol !== 'https:') throw new Error(`xAI discovery returned non-HTTPS ${field}`)
const host = url.hostname.toLowerCase()
if (host !== 'x.ai' && !host.endsWith('.x.ai')) {
throw new Error(`xAI discovery ${field} host is not on x.ai`)
}
return raw
}
async function requestJson(url: string, options: {
method?: string
headers?: Record<string, string>
body?: string
timeoutMs?: number
} = {}): Promise<{ status: number; text: string; json: any }> {
const target = new URL(url)
const timeoutMs = options.timeoutMs || 15000
const body = options.body || ''
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.headers || {}),
}
if (body && !headers['Content-Length']) headers['Content-Length'] = Buffer.byteLength(body).toString()
const requestOptions: RequestOptions = {
hostname: target.hostname,
port: Number(target.port || 443),
path: `${target.pathname}${target.search}`,
method: options.method || 'GET',
headers,
timeout: timeoutMs,
}
return await new Promise((resolve, reject) => {
const req = httpsRequest(requestOptions, (res) => {
const chunks: Buffer[] = []
res.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf-8')
let json: any = null
try { json = text ? JSON.parse(text) : null } catch {}
resolve({ status: res.statusCode || 0, text, json })
})
})
req.once('timeout', () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)))
req.once('error', reject)
if (body) req.write(body)
req.end()
})
}
async function discoverXai(): Promise<Record<string, string>> {
const res = await requestJson(XAI_OAUTH_DISCOVERY_URL, { timeoutMs: 15000 })
if (res.status < 200 || res.status >= 300) throw new Error(`xAI discovery failed: ${res.status}`)
const payload = res.json as Record<string, unknown>
if (!payload || typeof payload !== 'object') throw new Error('xAI discovery returned invalid JSON')
const authorizationEndpoint = String(payload.authorization_endpoint || '').trim()
const tokenEndpoint = String(payload.token_endpoint || '').trim()
if (!authorizationEndpoint || !tokenEndpoint) throw new Error('xAI discovery missing endpoints')
return {
authorization_endpoint: validateXaiEndpoint(authorizationEndpoint, 'authorization_endpoint'),
token_endpoint: validateXaiEndpoint(tokenEndpoint, 'token_endpoint'),
}
}
function loadAuthJson(authPath: string): AuthJson {
try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } }
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = dirname(authPath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function requestedProfile(ctx: any): string {
const headerProfile = typeof ctx.get === 'function' ? ctx.get('x-hermes-profile') : ''
const queryProfile = typeof ctx.query?.profile === 'string' ? ctx.query.profile : ''
const bodyProfile = typeof ctx.request?.body?.profile === 'string' ? ctx.request.body.profile : ''
return ctx.state?.profile?.name ||
headerProfile.trim() ||
queryProfile.trim() ||
bodyProfile.trim() ||
getActiveProfileName() ||
'default'
}
function authPathForProfile(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
export async function saveXaiOAuthTokensForProfile(
profile: string,
session: Pick<XaiSession, 'discovery' | 'redirectUri'>,
tokenData: any,
) {
const accessToken = String(tokenData.access_token || '').trim()
const refreshToken = String(tokenData.refresh_token || '').trim()
if (!accessToken || !refreshToken) throw new Error('xAI token response missing access_token or refresh_token')
const lastRefresh = new Date().toISOString()
const tokens = {
access_token: accessToken,
refresh_token: refreshToken,
id_token: String(tokenData.id_token || '').trim(),
expires_in: tokenData.expires_in,
token_type: String(tokenData.token_type || 'Bearer').trim() || 'Bearer',
}
const authPath = authPathForProfile(profile)
const auth = loadAuthJson(authPath)
if (!auth.providers) auth.providers = {}
auth.providers['xai-oauth'] = {
tokens,
last_refresh: lastRefresh,
auth_mode: 'oauth_pkce',
discovery: session.discovery,
redirect_uri: session.redirectUri,
}
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['xai-oauth'] = [{
id: `xai-oauth-${Date.now()}`,
label: 'xAI Grok OAuth (SuperGrok Subscription)',
auth_type: 'oauth',
source: 'loopback_pkce',
priority: 0,
access_token: accessToken,
refresh_token: refreshToken,
base_url: XAI_DEFAULT_BASE_URL,
}]
saveAuthJson(authPath, auth)
await updateConfigYamlForProfile(profile, applyXaiOAuthDefaultModel)
}
async function saveTokens(session: XaiSession, tokenData: any) {
await saveXaiOAuthTokensForProfile(session.profile, session, tokenData)
}
async function exchangeCode(session: XaiSession, code: string) {
const res = await requestJson(session.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: session.redirectUri,
client_id: XAI_OAUTH_CLIENT_ID,
code_verifier: session.codeVerifier,
}).toString(),
timeoutMs: 20000,
})
if (res.status < 200 || res.status >= 300) {
throw new Error(`xAI token exchange failed: ${res.status}${res.text ? ` ${res.text}` : ''}`)
}
await saveTokens(session, res.json)
}
function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_PORT): Promise<{ server: Server; redirectUri: string }> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const session = sessions.get(sessionId)
const url = new URL(req.url || '/', `http://${XAI_REDIRECT_HOST}`)
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': 'https://auth.x.ai',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
res.end()
return
}
if (!session || url.pathname !== XAI_REDIRECT_PATH) {
res.writeHead(404)
res.end('Not found.')
return
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end('<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>')
void (async () => {
try {
const error = url.searchParams.get('error')
if (error) throw new Error(url.searchParams.get('error_description') || error)
if (url.searchParams.get('state') !== session.state) throw new Error('xAI OAuth state mismatch')
const code = url.searchParams.get('code')
if (!code) throw new Error('xAI OAuth callback missing code')
await exchangeCode(session, code)
session.status = 'approved'
closeServer(session)
} catch (err: any) {
logger.error(err, 'xAI OAuth callback failed')
session.status = 'error'
session.error = err?.message || String(err)
closeServer(session)
}
})()
})
server.once('error', (err: any) => {
if (preferredPort !== 0 && err?.code === 'EADDRINUSE') {
startCallbackServer(sessionId, 0).then(resolve, reject)
} else {
reject(err)
}
})
server.listen(preferredPort, XAI_CALLBACK_BIND_HOST, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : preferredPort
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
})
})
}
export async function start(ctx: any) {
try {
cleanupExpiredSessions()
const sessionId = randomUUID()
const profile = requestedProfile(ctx)
const discovery = await discoverXai()
const codeVerifier = makeCodeVerifier()
const state = randomUUID().replace(/-/g, '')
const nonce = randomUUID().replace(/-/g, '')
const { server, redirectUri } = await startCallbackServer(sessionId)
const authorizeUrl = `${discovery.authorization_endpoint}?${new URLSearchParams({
response_type: 'code',
client_id: XAI_OAUTH_CLIENT_ID,
redirect_uri: redirectUri,
scope: XAI_OAUTH_SCOPE,
code_challenge: makeCodeChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
nonce,
plan: 'generic',
referrer: 'hermes-web-ui',
}).toString()}`
sessions.set(sessionId, {
id: sessionId,
profile,
status: 'pending',
authorizeUrl,
redirectUri,
codeVerifier,
state,
tokenEndpoint: discovery.token_endpoint,
discovery,
server,
createdAt: Date.now(),
})
ctx.body = { session_id: sessionId, authorization_url: authorizeUrl, expires_in: 900 }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function poll(ctx: any) {
const session = sessions.get(ctx.params.sessionId)
if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return }
if (Date.now() - session.createdAt > POLL_MAX_DURATION) {
session.status = 'expired'
closeServer(session)
}
ctx.body = { status: session.status, error: session.error || null }
}
export async function status(ctx: any) {
try {
const auth = loadAuthJson(authPathForProfile(requestedProfile(ctx)))
const provider = auth.providers?.['xai-oauth']
const pool = auth.credential_pool?.['xai-oauth']
ctx.body = {
authenticated: !!(
provider?.tokens?.access_token ||
provider?.access_token ||
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
),
last_refresh: provider?.last_refresh,
}
} catch {
ctx.body = { authenticated: false }
}
}
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
import { randomBytes } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { getActiveProfileName } from '../services/hermes/hermes-profile'
import { getProfileUploadDir } from '../services/hermes/upload-paths'
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
export async function handleUpload(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400; ctx.body = { error: 'Expected multipart/form-data' }; return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400; ctx.body = { error: 'Missing boundary' }; return
}
const chunks: Buffer[] = []
let totalSize = 0
for await (const chunk of ctx.req) {
totalSize += chunk.length
if (totalSize > MAX_UPLOAD_SIZE) {
ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)` }; return
}
chunks.push(chunk)
}
const raw = Buffer.concat(chunks)
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const results: { name: string; path: string }[] = []
const uploadDir = getProfileUploadDir(requestedProfile(ctx))
await mkdir(uploadDir, { recursive: true })
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
const headerBuf = part.subarray(0, headerEnd)
const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
let filename = ''
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
if (filenameStarMatch) { filename = decodeURIComponent(filenameStarMatch[1]) }
else {
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
filename = filenameMatch[1]
}
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = join(uploadDir, savedName)
await writeFile(savedPath, data)
results.push({ name: filename, path: savedPath })
}
ctx.body = { files: results }
}
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = raw.indexOf(boundary, start)
if (idx === -1) break
if (start > 0) { parts.push(raw.subarray(start + 2, idx)) }
start = idx + boundary.length
}
return parts
}
@@ -0,0 +1,12 @@
import { logger } from '../services/logger'
export async function handleWebhook(ctx: any) {
const payload = ctx.request.body
if (!payload || !payload.event) {
ctx.status = 400
ctx.body = { error: 'Missing event field' }
return
}
logger.info('Received webhook event: %s', payload.event)
ctx.body = { ok: true }
}
@@ -0,0 +1,40 @@
/**
* SQLite-backed compression snapshot store for 1:1 chat sessions.
*
* Stores the latest compression summary and the index of the last
* compressed message, so incremental compression can pick up where
* the previous one left off.
*/
import { isSqliteAvailable, getDb } from '../index'
import { COMPRESSION_SNAPSHOT_TABLE as TABLE } from './schemas'
export function getCompressionSnapshot(sessionId: string): { summary: string; lastMessageIndex: number; messageCountAtTime: number } | null {
if (!isSqliteAvailable()) return null
return getDb()!.prepare(
`SELECT summary, last_message_index AS lastMessageIndex, message_count_at_time AS messageCountAtTime FROM ${TABLE} WHERE session_id = ?`,
).get(sessionId) as any ?? null
}
export function saveCompressionSnapshot(
sessionId: string,
summary: string,
lastMessageIndex: number,
messageCountAtTime: number,
): void {
if (!isSqliteAvailable()) return
getDb()!.prepare(
`INSERT INTO ${TABLE} (session_id, summary, last_message_index, message_count_at_time, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
summary = excluded.summary,
last_message_index = excluded.last_message_index,
message_count_at_time = excluded.message_count_at_time,
updated_at = excluded.updated_at`,
).run(sessionId, summary, lastMessageIndex, messageCountAtTime, Date.now())
}
export function deleteCompressionSnapshot(sessionId: string): void {
if (!isSqliteAvailable()) return
getDb()!.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId)
}
@@ -0,0 +1,537 @@
import { join } from 'path'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import type {
ConversationDetail,
ConversationListOptions,
ConversationMessage,
ConversationSummary,
} from '../../services/hermes/conversations'
const SQLITE_AVAILABLE = (() => {
const [major, minor] = process.versions.node.split('.').map(Number)
return major > 22 || (major === 22 && minor >= 5)
})()
const LINEAGE_TOLERANCE_SECONDS = 3
const LIVE_WINDOW_SECONDS = 300
const DEFAULT_CONVERSATION_LIMIT = 200
const SYNTHETIC_USER_PREFIXES = [
'[system:',
"you've reached the maximum number of tool-calling iterations allowed.",
'you have reached the maximum number of tool-calling iterations allowed.',
]
const VISIBLE_HUMAN_MESSAGE_SQL = `
m.content IS NOT NULL
AND m.content != ''
AND (
m.role = 'assistant'
OR (
m.role = 'user'
AND LOWER(m.content) NOT LIKE '[system:%'
AND LOWER(m.content) NOT LIKE 'you''ve reached the maximum number of tool-calling iterations allowed.%'
AND LOWER(m.content) NOT LIKE 'you have reached the maximum number of tool-calling iterations allowed.%'
)
)
`
interface ConversationSessionRow {
id: string
source: string
user_id: string | null
model: string
title: string | null
parent_session_id: string | null
started_at: number
ended_at: number | null
end_reason: string | null
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
preview: string
last_active: number
has_visible_messages: boolean
is_active: boolean
}
function conversationDbPath(): string {
return join(getActiveProfileDir(), 'state.db')
}
function normalizeNumber(value: unknown, fallback = 0): number {
if (value == null || value === '') return fallback
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
function normalizeNullableNumber(value: unknown): number | null {
if (value == null || value === '') return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function normalizeNullableString(value: unknown): string | null {
if (value == null || value === '') return null
return String(value)
}
function safeText(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
function textFromContent(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
try {
const parsed = JSON.parse(trimmed)
const nested = textFromContent(parsed)
if (nested) return nested
} catch {
// Fall back to the original string below.
}
}
return value
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (Array.isArray(value)) {
return value
.map(item => textFromContent(item).trim())
.filter(Boolean)
.join('\n')
}
if (!value || typeof value !== 'object') return ''
const record = value as Record<string, unknown>
for (const key of ['text', 'content', 'value'] as const) {
const direct = record[key]
if (typeof direct === 'string') return direct
if (Array.isArray(direct)) {
const nested = textFromContent(direct)
if (nested) return nested
}
}
for (const key of ['parts', 'children', 'items'] as const) {
if (Array.isArray(record[key])) {
const nested = textFromContent(record[key])
if (nested) return nested
}
}
const flattened = Object.values(record)
.map(entry => textFromContent(entry).trim())
.filter(Boolean)
.join('\n')
if (flattened) return flattened
try {
return JSON.stringify(record)
} catch {
return ''
}
}
function normalizeText(value: unknown): string {
return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase()
}
function excerpt(value: unknown, width = 80): string {
const text = textFromContent(value).replace(/\s+/g, ' ').trim()
if (!text) return ''
return text.length > width ? `${text.slice(0, width)}` : text
}
function isSyntheticUserText(content: unknown): boolean {
const text = normalizeText(content)
return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix))
}
function mapSessionRow(row: Record<string, unknown>, nowSeconds: number): ConversationSessionRow {
const startedAt = normalizeNumber(row.started_at)
const endedAt = normalizeNullableNumber(row.ended_at)
const preview = excerpt(row.preview || '')
const rawTitle = normalizeNullableString(row.title)
const title = rawTitle || (preview ? (preview.length > 40 ? `${preview.slice(0, 40)}...` : preview) : null)
const lastActive = normalizeNumber(row.last_active, startedAt)
return {
id: String(row.id || ''),
source: String(row.source || ''),
user_id: normalizeNullableString(row.user_id),
model: String(row.model || ''),
title,
parent_session_id: normalizeNullableString(row.parent_session_id),
started_at: startedAt,
ended_at: endedAt,
end_reason: normalizeNullableString(row.end_reason),
message_count: normalizeNumber(row.message_count),
tool_call_count: normalizeNumber(row.tool_call_count),
input_tokens: normalizeNumber(row.input_tokens),
output_tokens: normalizeNumber(row.output_tokens),
cache_read_tokens: normalizeNumber(row.cache_read_tokens),
cache_write_tokens: normalizeNumber(row.cache_write_tokens),
reasoning_tokens: normalizeNumber(row.reasoning_tokens),
billing_provider: normalizeNullableString(row.billing_provider),
estimated_cost_usd: normalizeNumber(row.estimated_cost_usd),
actual_cost_usd: normalizeNullableNumber(row.actual_cost_usd),
cost_status: String(row.cost_status || ''),
preview,
last_active: lastActive,
has_visible_messages: !!normalizeNumber(row.has_visible_messages),
is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS,
}
}
function sortByRecency<T extends { last_active: number; started_at: number; id: string }>(items: T[]): T[] {
return [...items].sort((a, b) => {
if (b.last_active !== a.last_active) return b.last_active - a.last_active
if (b.started_at !== a.started_at) return b.started_at - a.started_at
return a.id.localeCompare(b.id)
})
}
function timingMatchesParent(parent: ConversationSessionRow | undefined, child: ConversationSessionRow | undefined): boolean {
if (!parent || !child || parent.ended_at == null) return false
return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS
}
function isCompressionEndReason(reason: string | null): boolean {
return reason === 'compression' || reason === 'compressed'
}
function continuationCandidates(parent: ConversationSessionRow, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): ConversationSessionRow[] {
const childIds = childrenByParent.get(parent.id) || []
return childIds
.map(childId => byId.get(childId))
.filter((child): child is ConversationSessionRow => !!child)
.filter(child => child.source !== 'tool')
.filter(child => child.source === parent.source)
.filter(child => timingMatchesParent(parent, child))
.sort((a, b) => {
const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0))
const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0))
if (aDelta !== bDelta) return aDelta - bDelta
return a.id.localeCompare(b.id)
})
}
function nextContinuationChild(parent: ConversationSessionRow, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): ConversationSessionRow | null {
if (!isCompressionEndReason(parent.end_reason)) return null
const candidates = continuationCandidates(parent, byId, childrenByParent)
if (candidates.length === 1) return candidates[0]
const exactPreviewMatches = candidates.filter(child => {
const childPreview = normalizeText(child.preview)
const parentPreview = normalizeText(parent.preview)
return !!childPreview && childPreview === parentPreview
})
if (exactPreviewMatches.length === 1) return exactPreviewMatches[0]
return null
}
function isCompressionContinuationChild(session: ConversationSessionRow | undefined, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): boolean {
if (!session?.parent_session_id) return false
const parent = byId.get(session.parent_session_id)
if (!parent) return false
return nextContinuationChild(parent, byId, childrenByParent)?.id === session.id
}
function compressionChainRootId(sessionId: string, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): string | null {
let current = byId.get(sessionId) || null
if (!current || current.source === 'tool') return null
const seen = new Set<string>()
while (current?.parent_session_id && !seen.has(current.id)) {
seen.add(current.id)
const parent = byId.get(current.parent_session_id)
if (!parent) break
if (nextContinuationChild(parent, byId, childrenByParent)?.id !== current.id) break
current = parent
}
return current?.id || null
}
function isVisibleConversationStart(session: ConversationSessionRow | undefined, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): boolean {
if (!session || session.source === 'tool') return false
return !isCompressionContinuationChild(session, byId, childrenByParent)
}
function collectConversationChain(rootId: string, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): ConversationSessionRow[] {
const chain: ConversationSessionRow[] = []
const seen = new Set<string>()
let current = byId.get(rootId) || null
while (current && !seen.has(current.id)) {
chain.push(current)
seen.add(current.id)
current = nextContinuationChild(current, byId, childrenByParent)
}
return chain
}
function toSummary(session: ConversationSessionRow): ConversationSummary {
return {
id: session.id,
source: safeText(session.source),
model: safeText(session.model),
title: session.title ?? null,
started_at: Number(session.started_at || 0),
ended_at: session.ended_at ?? null,
last_active: session.last_active,
message_count: Number(session.message_count || 0),
tool_call_count: Number(session.tool_call_count || 0),
input_tokens: Number(session.input_tokens || 0),
output_tokens: Number(session.output_tokens || 0),
cache_read_tokens: Number(session.cache_read_tokens || 0),
cache_write_tokens: Number(session.cache_write_tokens || 0),
reasoning_tokens: Number(session.reasoning_tokens || 0),
billing_provider: session.billing_provider ?? null,
estimated_cost_usd: Number(session.estimated_cost_usd || 0),
actual_cost_usd: session.actual_cost_usd ?? null,
cost_status: safeText(session.cost_status),
preview: session.preview,
is_active: session.is_active,
thread_session_count: 1,
}
}
function aggregateSummary(rootId: string, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): ConversationSummary | null {
const chain = collectConversationChain(rootId, byId, childrenByParent)
if (!chain.length || !chain.some(session => session.has_visible_messages)) return null
const root = chain[0]
const last = chain[chain.length - 1]
const firstPreview = chain.map(session => session.preview).find(Boolean) || ''
const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean)))
return {
...toSummary(last),
title: last.title || root.title || firstPreview || null,
preview: last.preview || root.preview || firstPreview,
started_at: Number(root.started_at || 0),
ended_at: last?.ended_at ?? null,
last_active: Math.max(...chain.map(session => session.last_active)),
is_active: chain.some(session => session.is_active),
billing_provider: last?.billing_provider ?? root.billing_provider ?? null,
cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed',
thread_session_count: chain.length,
message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0),
tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0),
input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0),
output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0),
cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0),
cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0),
reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0),
estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0),
actual_cost_usd: chain.reduce<number | null>((sum, session) => {
const actual = session.actual_cost_usd
if (actual == null) return sum
return (sum || 0) + Number(actual)
}, null),
}
}
function normalizeVisibleMessage(message: { id: number | string, session_id: string, role: string, content: unknown, timestamp: number }, fallbackTimestamp: number): ConversationMessage | null {
const role = safeText(message.role)
const content = textFromContent(message.content).trim()
if (!content) return null
if (role !== 'user' && role !== 'assistant') return null
if (role === 'user' && isSyntheticUserText(content)) return null
return {
id: message.id,
session_id: message.session_id,
role,
content,
timestamp: Number.isFinite(Number(message.timestamp)) && Number(message.timestamp) > 0
? Number(message.timestamp)
: fallbackTimestamp,
}
}
async function openConversationDb() {
if (!SQLITE_AVAILABLE) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
}
const { DatabaseSync } = await import('node:sqlite')
return new DatabaseSync(conversationDbPath(), { open: true, readOnly: true })
}
function buildConversationSessionSql(source?: string): { sql: string, params: any[] } {
const sql = `
SELECT
s.id,
s.source,
COALESCE(s.user_id, '') AS user_id,
COALESCE(s.model, '') AS model,
COALESCE(s.title, '') AS title,
s.parent_session_id AS parent_session_id,
COALESCE(s.started_at, 0) AS started_at,
s.ended_at AS ended_at,
COALESCE(s.end_reason, '') AS end_reason,
COALESCE(s.message_count, 0) AS message_count,
COALESCE(s.tool_call_count, 0) AS tool_call_count,
COALESCE(s.input_tokens, 0) AS input_tokens,
COALESCE(s.output_tokens, 0) AS output_tokens,
COALESCE(s.cache_read_tokens, 0) AS cache_read_tokens,
COALESCE(s.cache_write_tokens, 0) AS cache_write_tokens,
COALESCE(s.reasoning_tokens, 0) AS reasoning_tokens,
COALESCE(s.billing_provider, '') AS billing_provider,
COALESCE(s.estimated_cost_usd, 0) AS estimated_cost_usd,
s.actual_cost_usd AS actual_cost_usd,
COALESCE(s.cost_status, '') AS cost_status,
COALESCE(
(
SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 80)
FROM messages m
WHERE m.session_id = s.id
AND ${VISIBLE_HUMAN_MESSAGE_SQL}
ORDER BY m.timestamp, m.id
LIMIT 1
),
''
) AS preview,
COALESCE((SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), s.started_at) AS last_active,
CASE WHEN EXISTS (
SELECT 1
FROM messages m
WHERE m.session_id = s.id
AND ${VISIBLE_HUMAN_MESSAGE_SQL}
) THEN 1 ELSE 0 END AS has_visible_messages
FROM sessions s
WHERE s.source != 'tool'
${source ? 'AND s.source = ?' : ''}
ORDER BY s.started_at DESC
`
return { sql, params: source ? [source] : [] }
}
async function loadConversationSessions(source?: string): Promise<ConversationSessionRow[]> {
const db = await openConversationDb()
try {
const { sql, params } = buildConversationSessionSql(source)
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[]
const nowSeconds = Date.now() / 1000
return rows.map(row => mapSessionRow(row, nowSeconds))
} finally {
db.close()
}
}
export async function listConversationSummariesFromDb(options: ConversationListOptions = {}): Promise<ConversationSummary[]> {
const humanOnly = options.humanOnly !== false
const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT
const sessions = await loadConversationSessions(options.source)
const byId = new Map(sessions.map(session => [session.id, session]))
const childrenByParent = new Map<string | null, string[]>()
for (const session of sessions) {
const key = session.parent_session_id ?? null
const siblings = childrenByParent.get(key) || []
siblings.push(session.id)
childrenByParent.set(key, siblings)
}
if (!humanOnly) {
return sortByRecency(sessions.map(toSummary)).slice(0, limit)
}
const summaries = sessions
.filter(session => isVisibleConversationStart(session, byId, childrenByParent))
.map(session => aggregateSummary(session.id, byId, childrenByParent))
.filter((summary): summary is ConversationSummary => !!summary)
return sortByRecency(summaries).slice(0, limit)
}
export async function getConversationDetailFromDb(sessionId: string, options: ConversationListOptions = {}): Promise<ConversationDetail | null> {
const humanOnly = options.humanOnly !== false
const sessions = await loadConversationSessions(options.source)
const byId = new Map(sessions.map(session => [session.id, session]))
const childrenByParent = new Map<string | null, string[]>()
for (const session of sessions) {
const key = session.parent_session_id ?? null
const siblings = childrenByParent.get(key) || []
siblings.push(session.id)
childrenByParent.set(key, siblings)
}
let chain: ConversationSessionRow[] = []
if (!humanOnly) {
const session = byId.get(sessionId)
if (!session || session.source === 'tool') return null
chain = [session]
} else {
const session = byId.get(sessionId)
if (!session || session.source === 'tool') return null
const rootId = compressionChainRootId(sessionId, byId, childrenByParent)
if (!rootId) return null
if (!isVisibleConversationStart(byId.get(rootId), byId, childrenByParent)) return null
chain = collectConversationChain(rootId, byId, childrenByParent)
}
if (!chain.length) return null
const db = await openConversationDb()
try {
const ids = chain.map(session => session.id)
const placeholders = ids.map(() => '?').join(', ')
const rows = db.prepare(`
SELECT id, session_id, role, content, timestamp
FROM messages
WHERE session_id IN (${placeholders})
AND role IN ('user', 'assistant')
AND content IS NOT NULL
AND content != ''
ORDER BY timestamp, id
`).all(...ids) as Array<Record<string, unknown>>
const sessionById = new Map(chain.map(session => [session.id, session]))
const messages = rows
.map(row => {
const session = sessionById.get(String(row.session_id || ''))
return normalizeVisibleMessage({
id: row.id as number | string,
session_id: String(row.session_id || ''),
role: String(row.role || ''),
content: row.content,
timestamp: normalizeNumber(row.timestamp),
}, session?.last_active || session?.started_at || 0)
})
.filter((message): message is ConversationMessage => !!message)
.sort((a, b) => {
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return String(a.id).localeCompare(String(b.id))
})
if (!messages.length) {
return humanOnly
? null
: {
session_id: sessionId,
messages: [],
visible_count: 0,
thread_session_count: chain.length,
}
}
return {
session_id: sessionId,
messages,
visible_count: messages.length,
thread_session_count: chain.length,
}
} finally {
db.close()
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Unified initializer for all Hermes SQLite stores.
* Call this once at bootstrap to create/migrate all tables.
*
* All table schemas, creation, and migration logic are now centralized
* in schemas.ts to avoid duplication and ensure consistency.
*/
import { initAllHermesTables } from './schemas'
export function initAllStores(): void {
// Initialize all tables with centralized schema definitions and migrations
initAllHermesTables()
}
@@ -0,0 +1,104 @@
const IMAGE_PART_TYPES = new Set(['image', 'image_url', 'input_image'])
const DATA_IMAGE_RE = /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\r\n]+/g
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function isContentPart(value: unknown): value is Record<string, unknown> {
return isPlainRecord(value) && typeof value.type === 'string'
}
function summarizeContentParts(parts: unknown[]): string | null {
let sawContentPart = false
const text: string[] = []
for (const part of parts) {
if (!isContentPart(part)) continue
const type = String(part.type)
if (type === 'text') {
sawContentPart = true
const value = part.text
if (value != null) text.push(String(value))
} else if (IMAGE_PART_TYPES.has(type)) {
sawContentPart = true
text.push('[screenshot]')
}
}
return sawContentPart ? text.filter(Boolean).join('\n') : null
}
function summarizeMultimodalEnvelope(value: Record<string, unknown>): string | null {
if (value._multimodal !== true && !Array.isArray(value.content)) return null
const parts = Array.isArray(value.content) ? value.content : []
if (!parts.length) return null
return summarizeContentParts(parts)
}
function redactDataImages(value: unknown): unknown {
if (typeof value === 'string') return value.replace(DATA_IMAGE_RE, '[screenshot]')
if (Array.isArray(value)) return value.map(redactDataImages)
if (!isPlainRecord(value)) return value
const cleaned: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
cleaned[key] = redactDataImages(child)
}
return cleaned
}
function summarizeKnownMultimodalContent(value: unknown): string | null {
if (Array.isArray(value)) {
return summarizeContentParts(value)
}
if (isPlainRecord(value)) {
return summarizeMultimodalEnvelope(value)
}
return null
}
function serializeStructuredMessageContent(value: unknown): string | null {
const summary = summarizeKnownMultimodalContent(value)
if (summary != null) return summary
if (Array.isArray(value) || isPlainRecord(value)) return JSON.stringify(redactDataImages(value))
return null
}
function shouldTryParseStructuredString(value: string): boolean {
const trimmed = value.trim()
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return false
if (trimmed.includes('_multimodal') || trimmed.includes('data:image/')) return true
return (
trimmed.includes('"image_url"') ||
trimmed.includes('"input_image"') ||
trimmed.includes('"type":"image"') ||
trimmed.includes('"type": "image"')
)
}
export function normalizeMessageContentForStorage(content: unknown): string {
if (typeof content === 'string') {
if (shouldTryParseStructuredString(content)) {
try {
const parsed = JSON.parse(content.trim())
const summary = summarizeKnownMultimodalContent(parsed)
if (summary != null) return summary
return JSON.stringify(redactDataImages(parsed))
} catch {
// Fall back to direct redaction below.
}
}
return content.replace(DATA_IMAGE_RE, '[screenshot]')
}
const normalized = serializeStructuredMessageContent(content)
if (normalized != null) return normalized
return String(content ?? '')
}
export function normalizeMessageContentForStorageRole(role: string | undefined | null, content: string): string {
return role === 'user' ? content : normalizeMessageContentForStorage(content)
}
+395
View File
@@ -0,0 +1,395 @@
/**
* Centralized schema definitions for all Hermes SQLite tables.
* All table schemas are defined here for unified management and migration.
*/
// ============================================================================
// Usage Store (usage-store.ts)
// ============================================================================
export const USAGE_TABLE = 'session_usage'
export const USAGE_SCHEMA: Record<string, string> = {
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
session_id: 'TEXT NOT NULL',
input_tokens: 'INTEGER NOT NULL DEFAULT 0',
output_tokens: 'INTEGER NOT NULL DEFAULT 0',
cache_read_tokens: 'INTEGER NOT NULL DEFAULT 0',
cache_write_tokens: 'INTEGER NOT NULL DEFAULT 0',
reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0',
model: "TEXT NOT NULL DEFAULT ''",
profile: "TEXT NOT NULL DEFAULT 'default'",
created_at: 'INTEGER NOT NULL DEFAULT 0',
}
// ============================================================================
// Session Store (session-store.ts)
// ============================================================================
export const SESSIONS_TABLE = 'sessions'
export const SESSIONS_SCHEMA: Record<string, string> = {
id: 'TEXT PRIMARY KEY',
profile: 'TEXT NOT NULL DEFAULT \'default\'',
source: 'TEXT NOT NULL DEFAULT \'api_server\'',
user_id: 'TEXT',
model: 'TEXT NOT NULL DEFAULT \'\'',
provider: 'TEXT NOT NULL DEFAULT \'\'',
title: 'TEXT',
started_at: 'INTEGER NOT NULL',
ended_at: 'INTEGER',
end_reason: 'TEXT',
message_count: 'INTEGER NOT NULL DEFAULT 0',
tool_call_count: 'INTEGER NOT NULL DEFAULT 0',
input_tokens: 'INTEGER NOT NULL DEFAULT 0',
output_tokens: 'INTEGER NOT NULL DEFAULT 0',
cache_read_tokens: 'INTEGER NOT NULL DEFAULT 0',
cache_write_tokens: 'INTEGER NOT NULL DEFAULT 0',
reasoning_tokens: 'INTEGER NOT NULL DEFAULT 0',
billing_provider: 'TEXT',
estimated_cost_usd: 'REAL NOT NULL DEFAULT 0',
actual_cost_usd: 'REAL',
cost_status: 'TEXT NOT NULL DEFAULT \'\'',
preview: 'TEXT NOT NULL DEFAULT \'\'',
last_active: 'INTEGER NOT NULL',
workspace: 'TEXT',
}
export const MESSAGES_TABLE = 'messages'
export const MESSAGES_SCHEMA: Record<string, string> = {
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
session_id: 'TEXT NOT NULL',
role: 'TEXT NOT NULL',
content: 'TEXT NOT NULL DEFAULT \'\'',
tool_call_id: 'TEXT',
tool_calls: 'TEXT',
tool_name: 'TEXT',
timestamp: 'INTEGER NOT NULL',
token_count: 'INTEGER',
finish_reason: 'TEXT',
reasoning: 'TEXT',
reasoning_details: 'TEXT',
reasoning_content: 'TEXT',
}
export const MESSAGES_INDEX = 'CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)'
// ============================================================================
// Compression Snapshot (compression-snapshot.ts)
// ============================================================================
export const COMPRESSION_SNAPSHOT_TABLE = 'chat_compression_snapshots'
export const COMPRESSION_SNAPSHOT_SCHEMA: Record<string, string> = {
session_id: 'TEXT PRIMARY KEY',
summary: 'TEXT NOT NULL DEFAULT \'\'',
last_message_index: 'INTEGER NOT NULL DEFAULT 0',
message_count_at_time: 'INTEGER NOT NULL DEFAULT 0',
updated_at: 'INTEGER NOT NULL',
}
// ============================================================================
// Model Context (model-context.ts)
// ============================================================================
export const MODEL_CONTEXT_TABLE = 'model_context'
export const MODEL_CONTEXT_SCHEMA: Record<string, string> = {
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
provider: 'TEXT NOT NULL',
model: 'TEXT NOT NULL',
context_limit: 'INTEGER NOT NULL',
}
export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)'
// ============================================================================
// Users and Profile Access
// ============================================================================
export const USERS_TABLE = 'users'
export const USERS_SCHEMA: Record<string, string> = {
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
username: 'TEXT NOT NULL UNIQUE',
password_hash: 'TEXT NOT NULL',
role: "TEXT NOT NULL DEFAULT 'admin'",
status: "TEXT NOT NULL DEFAULT 'active'",
created_at: 'INTEGER NOT NULL',
updated_at: 'INTEGER NOT NULL',
last_login_at: 'INTEGER',
}
export const USER_PROFILES_TABLE = 'user_profiles'
export const USER_PROFILES_SCHEMA: Record<string, string> = {
user_id: 'INTEGER NOT NULL',
profile_name: "TEXT NOT NULL DEFAULT 'default'",
is_default: 'INTEGER NOT NULL DEFAULT 0',
created_at: 'INTEGER NOT NULL',
}
export const USER_PROFILES_INDEXES = {
idx_user_profiles_user: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_user ON user_profiles(user_id)',
idx_user_profiles_profile: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_profile ON user_profiles(profile_name)',
idx_user_profiles_default: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_default ON user_profiles(user_id) WHERE is_default = 1',
}
// ============================================================================
// Group Chat (services/hermes/group-chat/index.ts)
// ============================================================================
export const GC_ROOMS_TABLE = 'gc_rooms'
export const GC_ROOMS_SCHEMA: Record<string, string> = {
id: 'TEXT PRIMARY KEY',
name: 'TEXT NOT NULL',
inviteCode: 'TEXT UNIQUE',
triggerTokens: 'INTEGER NOT NULL DEFAULT 100000',
maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000',
tailMessageCount: 'INTEGER NOT NULL DEFAULT 10',
totalTokens: 'INTEGER NOT NULL DEFAULT 0',
sessionSeed: "TEXT NOT NULL DEFAULT '0'",
}
export const GC_MESSAGES_TABLE = 'gc_messages'
export const GC_MESSAGES_SCHEMA: Record<string, string> = {
id: 'TEXT PRIMARY KEY',
roomId: 'TEXT NOT NULL',
senderId: 'TEXT NOT NULL',
senderName: 'TEXT NOT NULL',
content: 'TEXT NOT NULL',
timestamp: 'INTEGER NOT NULL',
role: "TEXT NOT NULL DEFAULT 'user'",
tool_call_id: 'TEXT',
tool_calls: 'TEXT',
tool_name: 'TEXT',
finish_reason: 'TEXT',
reasoning: 'TEXT',
reasoning_details: 'TEXT',
reasoning_content: 'TEXT',
}
export const GC_ROOM_AGENTS_TABLE = 'gc_room_agents'
export const GC_ROOM_AGENTS_SCHEMA: Record<string, string> = {
id: 'TEXT PRIMARY KEY',
roomId: 'TEXT NOT NULL',
agentId: 'TEXT NOT NULL',
profile: 'TEXT NOT NULL',
name: 'TEXT NOT NULL',
description: "TEXT NOT NULL DEFAULT ''",
invited: 'INTEGER NOT NULL DEFAULT 0',
}
export const GC_CONTEXT_SNAPSHOTS_TABLE = 'gc_context_snapshots'
export const GC_CONTEXT_SNAPSHOTS_SCHEMA: Record<string, string> = {
roomId: 'TEXT PRIMARY KEY',
summary: 'TEXT NOT NULL DEFAULT \'\'',
lastMessageId: 'TEXT NOT NULL',
lastMessageTimestamp: 'INTEGER NOT NULL',
updatedAt: 'INTEGER NOT NULL',
}
export const GC_ROOM_MEMBERS_TABLE = 'gc_room_members'
export const GC_ROOM_MEMBERS_SCHEMA: Record<string, string> = {
id: 'TEXT PRIMARY KEY',
roomId: 'TEXT NOT NULL',
userId: 'TEXT NOT NULL',
userName: 'TEXT NOT NULL',
description: "TEXT NOT NULL DEFAULT ''",
joinedAt: 'INTEGER NOT NULL',
updatedAt: 'INTEGER NOT NULL',
}
export const GC_PENDING_SESSION_DELETES_TABLE = 'gc_pending_session_deletes'
export const GC_PENDING_SESSION_DELETES_SCHEMA: Record<string, string> = {
session_id: 'TEXT PRIMARY KEY',
profile_name: 'TEXT NOT NULL',
status: "TEXT NOT NULL DEFAULT 'pending'",
attempt_count: 'INTEGER NOT NULL DEFAULT 0',
last_error: 'TEXT',
created_at: 'INTEGER NOT NULL',
updated_at: 'INTEGER NOT NULL',
next_attempt_at: 'INTEGER NOT NULL DEFAULT 0',
}
export const GC_SESSION_PROFILES_TABLE = 'gc_session_profiles'
export const GC_SESSION_PROFILES_SCHEMA: Record<string, string> = {
session_id: 'TEXT PRIMARY KEY',
room_id: 'TEXT NOT NULL',
agent_id: 'TEXT NOT NULL',
profile_name: 'TEXT NOT NULL',
created_at: 'INTEGER NOT NULL',
}
// ============================================================================
// Schema Sync Utilities
// ============================================================================
import { getDb, getStoragePath } from '../index'
function quoteIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`
}
/**
* 检查表是否存在
*/
function tableExists(db: NonNullable<ReturnType<typeof getDb>>, tableName: string): boolean {
const result = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName)
return !!result
}
/**
* 创建表(带完整 schema
*/
function createTable(
db: NonNullable<ReturnType<typeof getDb>>,
tableName: string,
schema: Record<string, string>,
primaryKey?: string
): void {
const colDefs = Object.entries(schema).map(([col, def]) => `${quoteIdentifier(col)} ${def}`)
// 只在 schema 中没有主键时才添加复合主键
const hasPrimaryKeyInSchema = Object.values(schema).some((def) =>
def.toUpperCase().includes("PRIMARY KEY")
)
if (primaryKey && !hasPrimaryKeyInSchema) {
colDefs.push(`PRIMARY KEY (${primaryKey})`)
}
db.exec(`CREATE TABLE ${quoteIdentifier(tableName)} (${colDefs.join(', ')})`)
}
function canAddColumnToExistingTable(schemaDef: string): boolean {
const normalized = schemaDef.toUpperCase()
if (normalized.includes('PRIMARY KEY')) return false
if (normalized.includes('NOT NULL') && !normalized.includes('DEFAULT')) return false
return true
}
function addMissingSafeColumns(
db: NonNullable<ReturnType<typeof getDb>>,
tableName: string,
schema: Record<string, string>,
): void {
const columns = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as Array<{ name: string }>
const existingColumns = new Set(columns.map(col => col.name))
for (const [columnName, columnDef] of Object.entries(schema)) {
if (existingColumns.has(columnName)) continue
if (!canAddColumnToExistingTable(columnDef)) {
console.warn(`[Schema] ${tableName}.${columnName} cannot be added safely to existing table; skipping`)
continue
}
db.exec(`ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${quoteIdentifier(columnName)} ${columnDef}`)
}
}
/**
* 主同步函数
* - 表不存在:创建
* - 表存在:只追加安全的新列,不删除、不重建、不修改主键/类型
*/
export function syncTable(
tableName: string,
schema: Record<string, string>,
options?: {
primaryKey?: string // 主键定义,如 "roomId, agentId" 或 "id"
indexes?: Record<string, string> // 索引定义
}
): void {
const db = getDb()
if (!db) return
// 1. 表不存在 → 直接创建
if (!tableExists(db, tableName)) {
createTable(db, tableName, schema, options?.primaryKey)
// 创建索引
if (options?.indexes) {
for (const indexSQL of Object.values(options.indexes)) {
db.exec(indexSQL)
}
}
return
}
addMissingSafeColumns(db, tableName, schema)
}
// ============================================================================
// Unified Initializer
// ============================================================================
/**
* Initialize missing Hermes SQLite tables with proper schemas.
* Existing tables only receive safe additive columns.
* Call this once at application bootstrap.
*/
export function initAllHermesTables(): void {
const db = getDb()
if (!db) return
try {
// Usage store
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
// Session store
syncTable(SESSIONS_TABLE, SESSIONS_SCHEMA)
syncTable(MESSAGES_TABLE, MESSAGES_SCHEMA)
db.exec(MESSAGES_INDEX)
// Compression snapshot
syncTable(COMPRESSION_SNAPSHOT_TABLE, COMPRESSION_SNAPSHOT_SCHEMA)
// Model context
syncTable(MODEL_CONTEXT_TABLE, MODEL_CONTEXT_SCHEMA, {
indexes: {
idx_model_context_provider_model: MODEL_CONTEXT_INDEX,
}
})
// Users and profile access
syncTable(USERS_TABLE, USERS_SCHEMA)
syncTable(USER_PROFILES_TABLE, USER_PROFILES_SCHEMA, {
primaryKey: 'user_id, profile_name',
indexes: USER_PROFILES_INDEXES,
})
// Group chat - basic tables
syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA)
syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA)
syncTable(GC_CONTEXT_SNAPSHOTS_TABLE, GC_CONTEXT_SNAPSHOTS_SCHEMA)
syncTable(GC_PENDING_SESSION_DELETES_TABLE, GC_PENDING_SESSION_DELETES_SCHEMA)
syncTable(GC_SESSION_PROFILES_TABLE, GC_SESSION_PROFILES_SCHEMA)
// Group chat - single-column primary key tables (PRIMARY KEY in column definition)
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
indexes: {
idx_gc_room_agents_profile: 'CREATE INDEX idx_gc_room_agents_profile ON gc_room_agents(profile)',
}
})
syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, {
indexes: {
idx_gc_room_members_user: 'CREATE INDEX idx_gc_room_members_user ON gc_room_members(userId)',
}
})
} catch (e) {
console.error('Error initializing Hermes SQLite tables:', e)
console.error(`[Schema] Database initialization failed. Existing database was left untouched: ${getStoragePath()}`)
throw e
}
}
@@ -0,0 +1,490 @@
/**
* Self-built session database completely replaces Hermes CLI dependency.
* Uses the same ensureTable/getDb pattern as usage-store.ts.
*/
import { isSqliteAvailable, getDb } from '../index'
import { SESSIONS_TABLE, MESSAGES_TABLE } from './schemas'
import { normalizeMessageContentForStorageRole } from './message-content'
// Re-export types for compatibility with sessions-db.ts consumers
export interface HermesSessionRow {
id: string
profile: string
source: string
user_id: string | null
model: string
provider: string
title: string | null
started_at: number
ended_at: number | null
end_reason: string | null
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
preview: string
last_active: number
workspace: string | null
}
export interface HermesMessageRow {
id: number | string
session_id: string
role: string
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
reasoning_details?: string | null
reasoning_content?: string | null
}
export interface HermesSessionSearchRow extends HermesSessionRow {
snippet: string
matched_message_id: number | null
}
export interface HermesSessionDetailRow extends HermesSessionRow {
messages: HermesMessageRow[]
thread_session_count: number
}
// Note: Table schemas and initialization are now centralized in schemas.ts
// Tables are created automatically on bootstrap via initAllHermesTables()
// --- Helpers ---
function parseToolCalls(value: unknown): any[] | null {
if (value == null || value === '') return null
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function mapSessionRow(row: Record<string, unknown>): HermesSessionRow {
const rawTitle = row.title != null ? String(row.title) : null
const preview = String(row.preview || '')
const title = rawTitle || (preview ? (preview.length > 40 ? preview.slice(0, 40) + '...' : preview) : null)
return {
id: String(row.id || ''),
profile: String(row.profile || 'default'),
source: String(row.source || 'api_server'),
user_id: row.user_id != null ? String(row.user_id) : null,
model: String(row.model || ''),
provider: String(row.provider || ''),
title,
started_at: Number(row.started_at || 0),
ended_at: row.ended_at != null ? Number(row.ended_at) : null,
end_reason: row.end_reason != null ? String(row.end_reason) : null,
message_count: Number(row.message_count || 0),
tool_call_count: Number(row.tool_call_count || 0),
input_tokens: Number(row.input_tokens || 0),
output_tokens: Number(row.output_tokens || 0),
cache_read_tokens: Number(row.cache_read_tokens || 0),
cache_write_tokens: Number(row.cache_write_tokens || 0),
reasoning_tokens: Number(row.reasoning_tokens || 0),
billing_provider: row.billing_provider != null ? String(row.billing_provider) : null,
estimated_cost_usd: Number(row.estimated_cost_usd || 0),
actual_cost_usd: row.actual_cost_usd != null ? Number(row.actual_cost_usd) : null,
cost_status: String(row.cost_status || ''),
preview: String(row.preview || ''),
last_active: Number(row.last_active || 0),
workspace: row.workspace != null ? String(row.workspace) : null,
}
}
function mapMessageRow(row: Record<string, unknown>): HermesMessageRow {
return {
id: typeof row.id === 'number' ? row.id : Number(row.id),
session_id: String(row.session_id || ''),
role: String(row.role || ''),
content: row.content != null ? String(row.content) : '',
tool_call_id: row.tool_call_id != null ? String(row.tool_call_id) : null,
tool_calls: parseToolCalls(row.tool_calls),
tool_name: row.tool_name != null ? String(row.tool_name) : null,
timestamp: Number(row.timestamp || 0),
token_count: row.token_count != null ? Number(row.token_count) : null,
finish_reason: row.finish_reason != null ? String(row.finish_reason) : null,
reasoning: row.reasoning != null ? String(row.reasoning) : null,
reasoning_details: row.reasoning_details != null ? String(row.reasoning_details) : null,
reasoning_content: row.reasoning_content != null ? String(row.reasoning_content) : null,
}
}
// --- Session CRUD ---
export function createSession(data: {
id: string
profile?: string
source?: string
model?: string
provider?: string
title?: string
workspace?: string
}): HermesSessionRow {
const now = Math.floor(Date.now() / 1000)
const source = data.source || 'api_server'
if (!isSqliteAvailable()) {
return {
id: data.id, profile: data.profile || 'default', source,
user_id: null, model: data.model || '', provider: data.provider || '', title: data.title || null,
started_at: now, ended_at: null, end_reason: null,
message_count: 0, tool_call_count: 0,
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0,
billing_provider: null, estimated_cost_usd: 0, actual_cost_usd: null,
cost_status: '', preview: '', last_active: now, workspace: data.workspace || null,
}
}
const db = getDb()!
db.prepare(
`INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, provider, title, started_at, last_active, workspace)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(data.id, data.profile || 'default', source, data.model || '', data.provider || '', data.title || null, now, now, data.workspace || null)
return getSession(data.id)!
}
export function getSession(id: string): HermesSessionRow | null {
if (!isSqliteAvailable()) return null
const db = getDb()!
const row = db.prepare(
`SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`,
).get(id) as Record<string, unknown> | undefined
return row ? mapSessionRow(row) : null
}
export function updateSession(id: string, data: Partial<Omit<HermesSessionRow, 'id' | 'profile'>>): void {
if (!isSqliteAvailable()) return
const db = getDb()!
const fields: string[] = []
const values: any[] = []
for (const [key, val] of Object.entries(data)) {
if (key === 'id' || key === 'profile') continue
// Skip last_active and ended_at - handle them separately below
if (key === 'last_active' || key === 'ended_at') continue
fields.push(`"${key}" = ?`)
values.push(val)
}
// Handle ended_at - only update if provided, otherwise keep existing value
if (data.ended_at !== undefined) {
fields.push(`"ended_at" = ?`)
values.push(data.ended_at)
}
// Handle last_active - use provided value or current time
if (data.last_active !== undefined) {
fields.push(`"last_active" = ?`)
values.push(data.last_active)
}
if (fields.length === 0) return
db.prepare(`UPDATE ${SESSIONS_TABLE} SET ${fields.join(', ')} WHERE id = ?`).run(...values, id)
}
export function deleteSession(id: string): boolean {
if (!isSqliteAvailable()) return false
const db = getDb()!
db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id)
const result = db.prepare(`DELETE FROM ${SESSIONS_TABLE} WHERE id = ?`).run(id)
return result.changes > 0
}
export function clearSessionMessages(id: string): number {
if (!isSqliteAvailable()) return 0
const db = getDb()!
const result = db.prepare(`DELETE FROM ${MESSAGES_TABLE} WHERE session_id = ?`).run(id)
updateSessionStats(id)
return Number(result.changes)
}
export function renameSession(id: string, title: string): boolean {
if (!isSqliteAvailable()) return false
const db = getDb()!
const result = db.prepare(`UPDATE ${SESSIONS_TABLE} SET title = ? WHERE id = ?`).run(title, id)
return result.changes > 0
}
export function listSessions(profile?: string, source?: string, limit = 2000): HermesSessionRow[] {
if (!isSqliteAvailable()) return []
const db = getDb()!
const profileFilter = profile?.trim()
// Use a subquery to generate preview from first user message if not set
const sql = `
SELECT
s.*,
COALESCE(
s.preview,
(
SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 63)
FROM ${MESSAGES_TABLE} m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id
LIMIT 1
),
''
) AS preview
FROM ${SESSIONS_TABLE} s
WHERE 1 = 1
${profileFilter ? 'AND s.profile = ?' : ''}
${source ? 'AND s.source = ?' : ''}
ORDER BY s.last_active DESC
LIMIT ?
`
const params: any[] = []
if (profileFilter) {
params.push(profileFilter)
}
if (source) {
params.push(source)
}
params.push(limit)
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[]
return rows.map(mapSessionRow)
}
export function searchSessions(profile: string | null | undefined, query: string, limit = 20): HermesSessionSearchRow[] {
if (!isSqliteAvailable()) return []
const profileFilter = profile?.trim()
const trimmed = query.trim()
if (!trimmed) {
return listSessions(profileFilter, undefined, limit).map(s => ({ ...s, snippet: s.preview || '', matched_message_id: null }))
}
const db = getDb()!
const lowered = trimmed.toLowerCase()
const pattern = `%${lowered}%`
// Step 1: Find matching sessions
const sessionRows = db.prepare(
`SELECT * FROM ${SESSIONS_TABLE}
WHERE 1 = 1
${profileFilter ? 'AND profile = ?' : ''}
AND (
LOWER(title) LIKE ? OR LOWER(preview) LIKE ?
OR id IN (SELECT DISTINCT session_id FROM ${MESSAGES_TABLE} WHERE LOWER(content) LIKE ? OR LOWER(COALESCE(tool_name, '')) LIKE ?)
)
ORDER BY last_active DESC LIMIT ?`,
).all(...[
...(profileFilter ? [profileFilter] : []),
pattern,
pattern,
pattern,
pattern,
limit,
]) as Record<string, unknown>[]
if (sessionRows.length === 0) return []
// Step 2: For each session, find first matching message id + snippet
const msgQuery = db.prepare(
`SELECT id, content, tool_name FROM ${MESSAGES_TABLE}
WHERE session_id = ? AND (LOWER(content) LIKE ? OR LOWER(COALESCE(tool_name, '')) LIKE ?)
ORDER BY timestamp, id LIMIT 1`,
)
return sessionRows.map(row => {
const session = mapSessionRow(row)
let snippet = ''
let matched_message_id: number | null = null
// Check if session title or preview matches
const titleLower = (session.title || '').toLowerCase()
const previewLower = (session.preview || '').toLowerCase()
const titleIdx = titleLower.indexOf(lowered)
const previewIdx = previewLower.indexOf(lowered)
if (titleIdx >= 0) {
snippet = session.title!.substring(Math.max(0, titleIdx - 20), titleIdx + lowered.length + 60)
} else if (previewIdx >= 0) {
snippet = session.preview.substring(Math.max(0, previewIdx - 20), previewIdx + lowered.length + 60)
} else {
// Get snippet from matching message
const msg = msgQuery.get(session.id, pattern, pattern) as { id: number; content: string; tool_name: string | null } | undefined
if (msg) {
matched_message_id = msg.id
const contentLower = msg.content.toLowerCase()
const idx = contentLower.indexOf(lowered)
snippet = msg.content.substring(Math.max(0, idx - 20), idx + lowered.length + 60)
}
}
return { ...session, snippet, matched_message_id }
})
}
export interface PaginatedSessionDetailResult {
session: HermesSessionRow
messages: HermesMessageRow[]
total: number
offset: number
limit: number
hasMore: boolean
}
export function getSessionDetail(id: string): HermesSessionDetailRow | null {
if (!isSqliteAvailable()) return null
const db = getDb()!
const sessionRow = db.prepare(`SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`).get(id) as Record<string, unknown> | undefined
if (!sessionRow) return null
const msgRows = db.prepare(
`SELECT * FROM ${MESSAGES_TABLE} WHERE session_id = ? ORDER BY id`,
).all(id) as Record<string, unknown>[]
const session = mapSessionRow(sessionRow)
return {
...session,
messages: msgRows.map(mapMessageRow),
thread_session_count: 1,
}
}
// --- Message CRUD ---
export function addMessage(msg: {
session_id: string
role: string
content: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
timestamp?: number
token_count?: number | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
}): number | undefined {
if (!isSqliteAvailable()) return undefined
const db = getDb()!
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
const result = db.prepare(
`INSERT INTO ${MESSAGES_TABLE} (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_details, reasoning_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
msg.session_id, msg.role, normalizeMessageContentForStorageRole(msg.role, msg.content),
msg.tool_call_id ?? null, toolCallsJson, msg.tool_name ?? null,
msg.timestamp ?? Math.floor(Date.now() / 1000),
msg.token_count ?? null, msg.finish_reason ?? null,
msg.reasoning ?? null, msg.reasoning_details ?? null,
msg.reasoning_content ?? null,
)
return result.lastInsertRowid as number
}
export function addMessages(msgs: Array<{
session_id: string
role: string
content: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
timestamp?: number
token_count?: number | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
}>): void {
if (!isSqliteAvailable() || msgs.length === 0) return
const db = getDb()!
const insert = db.prepare(
`INSERT INTO ${MESSAGES_TABLE} (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_details, reasoning_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
db.exec('BEGIN')
try {
for (const msg of msgs) {
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
insert.run(
msg.session_id, msg.role, normalizeMessageContentForStorageRole(msg.role, msg.content),
msg.tool_call_id ?? null, toolCallsJson, msg.tool_name ?? null,
msg.timestamp ?? Math.floor(Date.now() / 1000),
msg.token_count ?? null, msg.finish_reason ?? null,
msg.reasoning ?? null, msg.reasoning_details ?? null,
msg.reasoning_content ?? null,
)
}
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}
export function getMessageCount(sessionId: string): number {
if (!isSqliteAvailable()) return 0
const db = getDb()!
const row = db.prepare(
`SELECT COUNT(*) as cnt FROM ${MESSAGES_TABLE} WHERE session_id = ?`,
).get(sessionId) as { cnt: number } | undefined
return row?.cnt ?? 0
}
export function updateSessionStats(id: string): void {
if (!isSqliteAvailable()) return
const db = getDb()!
db.prepare(
`UPDATE ${SESSIONS_TABLE}
SET message_count = (SELECT COUNT(*) FROM ${MESSAGES_TABLE} WHERE session_id = ?),
last_active = COALESCE((SELECT MAX(timestamp) FROM ${MESSAGES_TABLE} WHERE session_id = ?), started_at)
WHERE id = ?`,
).run(id, id, id)
console.log(`Updated session ${id} stats`)
}
export function getSessionDetailPaginated(
id: string,
offset = 0,
limit = 300,
): PaginatedSessionDetailResult | null {
if (!isSqliteAvailable()) {
return null
}
const db = getDb()!
// Get session info
const sessionRow = db.prepare(`SELECT * FROM ${SESSIONS_TABLE} WHERE id = ?`).get(id) as Record<string, unknown> | undefined
if (!sessionRow) return null
// Get total message count
const countResult = db.prepare(
`SELECT COUNT(*) as total FROM ${MESSAGES_TABLE} WHERE session_id = ?`,
).get(id) as { total: number } | undefined
const total = countResult?.total || 0
// Get paginated messages (newest first from DB, then reverse).
// Timestamp precision is mixed across message sources; id is insertion order.
const msgRows = db.prepare(
`SELECT * FROM ${MESSAGES_TABLE} WHERE session_id = ? ORDER BY id DESC LIMIT ? OFFSET ?`,
).all(id, limit, offset) as Record<string, unknown>[]
const session = mapSessionRow(sessionRow)
const messages = msgRows.map(mapMessageRow).reverse() // Reverse to show oldest first
return {
session,
messages,
total,
offset,
limit,
hasMore: offset + messages.length < total,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,227 @@
import { isSqliteAvailable, getDb, jsonSet, jsonGet, jsonGetAll, jsonDelete } from '../index'
import { USAGE_TABLE as TABLE } from './schemas'
export interface UsageRecord {
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
model: string
profile: string
created_at: number
}
export function updateUsage(
sessionId: string,
data: {
inputTokens: number
outputTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
reasoningTokens?: number
model?: string
profile?: string
},
): void {
const cacheReadTokens = data.cacheReadTokens ?? 0
const cacheWriteTokens = data.cacheWriteTokens ?? 0
const reasoningTokens = data.reasoningTokens ?? 0
const now = Date.now()
const model = data.model || ''
const profile = data.profile || 'default'
if (isSqliteAvailable()) {
const db = getDb()!
db.prepare(
`INSERT INTO ${TABLE} (session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(sessionId, data.inputTokens, data.outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, model, profile, now)
} else {
jsonSet(TABLE, sessionId, {
input_tokens: data.inputTokens,
output_tokens: data.outputTokens,
cache_read_tokens: cacheReadTokens,
cache_write_tokens: cacheWriteTokens,
reasoning_tokens: reasoningTokens,
model,
profile,
created_at: now,
})
}
}
export function getUsage(sessionId: string): UsageRecord | undefined {
if (isSqliteAvailable()) {
return getDb()!.prepare(
`SELECT session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at FROM ${TABLE} WHERE session_id = ? ORDER BY id DESC LIMIT 1`,
).get(sessionId) as UsageRecord | undefined
}
const row = jsonGet(TABLE, sessionId)
if (!row) return undefined
return {
input_tokens: row.input_tokens ?? 0,
output_tokens: row.output_tokens ?? 0,
cache_read_tokens: row.cache_read_tokens ?? 0,
cache_write_tokens: row.cache_write_tokens ?? 0,
reasoning_tokens: row.reasoning_tokens ?? 0,
model: row.model ?? '',
profile: row.profile ?? 'default',
created_at: row.created_at ?? 0,
}
}
export function getUsageBatch(sessionIds: string[]): Record<string, UsageRecord> {
if (sessionIds.length === 0) return {}
if (isSqliteAvailable()) {
const db = getDb()!
const placeholders = sessionIds.map(() => '?').join(',')
const rows = db.prepare(
`SELECT session_id, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, model, profile, created_at
FROM ${TABLE}
WHERE id IN (SELECT MAX(id) FROM ${TABLE} WHERE session_id IN (${placeholders}) GROUP BY session_id)`,
).all(...sessionIds) as unknown as Array<UsageRecord & { session_id: string }>
const map: Record<string, UsageRecord> = {}
for (const r of rows) {
map[r.session_id] = {
input_tokens: r.input_tokens,
output_tokens: r.output_tokens,
cache_read_tokens: r.cache_read_tokens,
cache_write_tokens: r.cache_write_tokens,
reasoning_tokens: r.reasoning_tokens,
model: r.model,
profile: r.profile,
created_at: r.created_at,
}
}
return map
}
const all = jsonGetAll(TABLE)
const map: Record<string, UsageRecord> = {}
for (const id of sessionIds) {
const row = all[id]
if (row) {
map[id] = {
input_tokens: row.input_tokens ?? 0,
output_tokens: row.output_tokens ?? 0,
cache_read_tokens: row.cache_read_tokens ?? 0,
cache_write_tokens: row.cache_write_tokens ?? 0,
reasoning_tokens: row.reasoning_tokens ?? 0,
model: row.model ?? '',
profile: row.profile ?? 'default',
created_at: row.created_at ?? 0,
}
}
}
return map
}
export function deleteUsage(sessionId: string): void {
if (isSqliteAvailable()) {
getDb()!.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId)
} else {
jsonDelete(TABLE, sessionId)
}
}
// --- Aggregation for stats endpoint ---
export interface UsageStatsModelRow {
model: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
sessions: number
}
export interface UsageStatsDailyRow {
date: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
sessions: number
errors: number
cost: number
}
export interface LocalUsageStats {
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
sessions: number
by_model: UsageStatsModelRow[]
by_day: UsageStatsDailyRow[]
}
export function getLocalUsageStats(profile?: string, days = 30): LocalUsageStats {
const empty: LocalUsageStats = {
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0,
by_model: [], by_day: [],
}
if (!isSqliteAvailable()) return empty
const db = getDb()!
const safeDays = Math.max(1, Math.floor(Number.isFinite(days) ? days : 30))
const cutoffMs = Date.now() - safeDays * 24 * 60 * 60 * 1000
const filters: string[] = ['created_at > ?']
const params: any[] = [cutoffMs]
if (profile) {
filters.unshift('profile = ?')
params.unshift(profile)
}
const whereClause = `WHERE ${filters.join(' AND ')}`
const totals = db.prepare(`
SELECT COALESCE(SUM(input_tokens),0) as input_tokens,
COALESCE(SUM(output_tokens),0) as output_tokens,
COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens,
COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens,
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
${whereClause}
`).get(...params) as any
const byModel = db.prepare(`
SELECT model,
COALESCE(SUM(input_tokens),0) as input_tokens,
COALESCE(SUM(output_tokens),0) as output_tokens,
COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens,
COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens,
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
${whereClause}
GROUP BY model
ORDER BY COALESCE(SUM(input_tokens),0) + COALESCE(SUM(output_tokens),0) DESC
`).all(...params) as unknown as UsageStatsModelRow[]
const byDay = db.prepare(`
SELECT DATE(created_at / 1000, 'unixepoch') as date,
COALESCE(SUM(input_tokens),0) as input_tokens,
COALESCE(SUM(output_tokens),0) as output_tokens,
COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens,
COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens,
COUNT(DISTINCT session_id) as sessions
FROM ${TABLE}
${whereClause}
GROUP BY date
ORDER BY date
`).all(...params) as Array<{ date: string; input_tokens: number; output_tokens: number; cache_read_tokens: number; cache_write_tokens: number; sessions: number }>
return {
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
cache_read_tokens: totals.cache_read_tokens,
cache_write_tokens: totals.cache_write_tokens,
reasoning_tokens: totals.reasoning_tokens,
sessions: totals.sessions,
by_model: byModel,
by_day: byDay.map(d => ({ ...d, errors: 0, cost: 0 })),
}
}
@@ -0,0 +1,300 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'
import { getDb } from '../index'
import { USER_PROFILES_TABLE, USERS_TABLE } from './schemas'
export type UserRole = 'super_admin' | 'admin'
export type UserStatus = 'active' | 'disabled'
export type UserId = number | string
export interface UserRecord {
id: number
username: string
password_hash: string
role: UserRole
status: UserStatus
created_at: number
updated_at: number
last_login_at: number | null
}
export interface UserProfileRecord {
user_id: number
profile_name: string
is_default: number
created_at: number
}
export interface UserSummary {
id: number
username: string
role: UserRole
status: UserStatus
profiles: string[]
default_profile: string | null
created_at: number
updated_at: number
last_login_at: number | null
}
export const DEFAULT_USERNAME = 'admin'
export const DEFAULT_PASSWORD = '123456'
export const DEFAULT_PROFILE_NAME = 'default'
const SCRYPT_KEY_LEN = 64
function normalizeUserId(id: UserId): number | null {
const userId = typeof id === 'number' ? id : Number(id)
return Number.isInteger(userId) && userId > 0 ? userId : null
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, SCRYPT_KEY_LEN).toString('hex')
return `scrypt:${salt}:${hash}`
}
export function verifyPassword(password: string, passwordHash: string): boolean {
const [scheme, salt, expectedHex] = passwordHash.split(':')
if (scheme !== 'scrypt' || !salt || !expectedHex) return false
try {
const expected = Buffer.from(expectedHex, 'hex')
const actual = scryptSync(password, salt, expected.length)
return actual.length === expected.length && timingSafeEqual(actual, expected)
} catch {
return false
}
}
export function findUserById(id: UserId): UserRecord | null {
const db = getDb()
if (!db) return null
const userId = normalizeUserId(id)
if (!userId) return null
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE id = ?`).get(userId) as UserRecord | undefined
return row || null
}
export function findUserByUsername(username: string): UserRecord | null {
const db = getDb()
if (!db) return null
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE username = ?`).get(username) as UserRecord | undefined
return row || null
}
export function findFirstUser(): UserRecord | null {
const db = getDb()
if (!db) return null
const row = db.prepare(`SELECT * FROM ${USERS_TABLE} ORDER BY id ASC LIMIT 1`).get() as UserRecord | undefined
return row || null
}
export function listUsers(): UserSummary[] {
const db = getDb()
if (!db) return []
const users = db.prepare(
`SELECT id, username, role, status, created_at, updated_at, last_login_at FROM ${USERS_TABLE} ORDER BY id ASC`
).all() as Array<Omit<UserSummary, 'profiles' | 'default_profile'>>
return users.map(user => {
const profiles = listUserProfiles(user.id)
return {
...user,
profiles: profiles.map(profile => profile.profile_name),
default_profile: profiles.find(profile => profile.is_default === 1)?.profile_name || null,
}
})
}
export function listUserProfiles(userId: UserId): UserProfileRecord[] {
const db = getDb()
if (!db) return []
const id = normalizeUserId(userId)
if (!id) return []
return db.prepare(
`SELECT * FROM ${USER_PROFILES_TABLE} WHERE user_id = ? ORDER BY is_default DESC, profile_name ASC`
).all(id) as unknown as UserProfileRecord[]
}
export function userCanAccessProfile(userId: UserId, profileName: string): boolean {
const db = getDb()
if (!db) return false
const id = normalizeUserId(userId)
if (!id) return false
const row = db.prepare(
`SELECT 1 FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND profile_name = ?`
).get(id, profileName)
return !!row
}
export function getDefaultProfileForUser(userId: UserId): string {
const db = getDb()
if (!db) return DEFAULT_PROFILE_NAME
const id = normalizeUserId(userId)
if (!id) return DEFAULT_PROFILE_NAME
const row = db.prepare(
`SELECT profile_name FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND is_default = 1 LIMIT 1`
).get(id) as { profile_name?: string } | undefined
return row?.profile_name || DEFAULT_PROFILE_NAME
}
export function countUsers(): number {
const db = getDb()
if (!db) return 0
const row = db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE}`).get() as { count?: number } | undefined
return Number(row?.count || 0)
}
export function countActiveSuperAdmins(excludeUserId?: UserId): number {
const db = getDb()
if (!db) return 0
const exclude = excludeUserId == null ? null : normalizeUserId(excludeUserId)
const row = exclude
? db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active' AND id != ?`).get(exclude)
: db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active'`).get()
return Number((row as { count?: number } | undefined)?.count || 0)
}
export function touchUserLogin(userId: UserId, at = Date.now()): void {
const db = getDb()
if (!db) return
const id = normalizeUserId(userId)
if (!id) return
db.prepare(`UPDATE ${USERS_TABLE} SET last_login_at = ?, updated_at = ? WHERE id = ?`).run(at, at, id)
}
export function updateUserPassword(userId: UserId, password: string): boolean {
const db = getDb()
if (!db) return false
const id = normalizeUserId(userId)
if (!id) return false
const result = db.prepare(`UPDATE ${USERS_TABLE} SET password_hash = ?, updated_at = ? WHERE id = ?`)
.run(hashPassword(password), Date.now(), id)
return result.changes > 0
}
export function updateUsername(userId: UserId, username: string): boolean {
const db = getDb()
if (!db) return false
const id = normalizeUserId(userId)
if (!id) return false
const result = db.prepare(`UPDATE ${USERS_TABLE} SET username = ?, updated_at = ? WHERE id = ?`)
.run(username, Date.now(), id)
return result.changes > 0
}
export function createUser(input: {
username: string
password: string
role?: UserRole
status?: UserStatus
profiles?: string[]
defaultProfile?: string | null
}): UserRecord | null {
const db = getDb()
if (!db) return null
const now = Date.now()
const role = input.role || 'admin'
const status = input.status || 'active'
db.prepare(
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(input.username, hashPassword(input.password), role, status, now, now)
const user = findUserByUsername(input.username)
if (user) replaceUserProfiles(user.id, input.profiles || [], input.defaultProfile)
return user
}
export function updateUser(input: {
userId: UserId
username?: string
role?: UserRole
status?: UserStatus
password?: string
profiles?: string[]
defaultProfile?: string | null
}): UserRecord | null {
const db = getDb()
if (!db) return null
const id = normalizeUserId(input.userId)
if (!id) return null
const current = findUserById(id)
if (!current) return null
const nextUsername = input.username ?? current.username
const nextRole = input.role ?? current.role
const nextStatus = input.status ?? current.status
const nextPasswordHash = input.password ? hashPassword(input.password) : current.password_hash
const now = Date.now()
db.prepare(
`UPDATE ${USERS_TABLE}
SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ?
WHERE id = ?`
).run(nextUsername, nextPasswordHash, nextRole, nextStatus, now, id)
if (input.profiles) replaceUserProfiles(id, input.profiles, input.defaultProfile)
return findUserById(id)
}
export function deleteUser(userId: UserId): boolean {
const db = getDb()
if (!db) return false
const id = normalizeUserId(userId)
if (!id) return false
db.exec('BEGIN')
try {
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
const result = db.prepare(`DELETE FROM ${USERS_TABLE} WHERE id = ?`).run(id)
db.exec('COMMIT')
return result.changes > 0
} catch (err) {
db.exec('ROLLBACK')
throw err
}
}
export function replaceUserProfiles(userId: UserId, profiles: string[], defaultProfile?: string | null): void {
const db = getDb()
if (!db) return
const id = normalizeUserId(userId)
if (!id) return
const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))]
const defaultName = defaultProfile && uniqueProfiles.includes(defaultProfile) ? defaultProfile : uniqueProfiles[0] || null
const now = Date.now()
db.exec('BEGIN')
try {
db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id)
const stmt = db.prepare(
`INSERT INTO ${USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) VALUES (?, ?, ?, ?)`
)
uniqueProfiles.forEach(profile => {
stmt.run(id, profile, profile === defaultName ? 1 : 0, now)
})
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
}
export function createDefaultSuperAdmin(): UserRecord | null {
const db = getDb()
if (!db) return null
const now = Date.now()
db.prepare(
`INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(DEFAULT_USERNAME, hashPassword(DEFAULT_PASSWORD), 'super_admin', 'active', now, now)
return findUserByUsername(DEFAULT_USERNAME)
}
export function bootstrapDefaultSuperAdmin(username: string, password: string): UserRecord | null {
if (countUsers() > 0) return null
if (username !== DEFAULT_USERNAME || password !== DEFAULT_PASSWORD) return null
return createDefaultSuperAdmin()
}
+128
View File
@@ -0,0 +1,128 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { config } from '../config'
const isDev = process.env.NODE_ENV !== 'production'
const isTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
// In WSL, always use home directory to avoid cross-filesystem issues
const DB_DIR = isTest
? resolve(process.cwd(), 'packages/server/data/test-runtime')
: isDev
? resolve(process.cwd(), 'packages/server/data')
: config.appHome
const DB_PATH = resolve(DB_DIR, 'hermes-web-ui.db')
const JSON_PATH = resolve(DB_DIR, 'hermes-web-ui.json')
// --- SQLite availability check ---
const SQLITE_AVAILABLE = (() => {
const [major, minor] = process.versions.node.split('.').map(Number)
return major > 22 || (major === 22 && minor >= 5)
})()
export function isSqliteAvailable(): boolean {
return SQLITE_AVAILABLE
}
// --- SQLite backend ---
let _db: DatabaseSync | null = null
export function getDb(): DatabaseSync | null {
if (!SQLITE_AVAILABLE) return null
if (!_db) {
mkdirSync(DB_DIR, { recursive: true })
_db = new DatabaseSync(DB_PATH)
// Use WAL mode for better concurrency and WSL compatibility
if (isDev) {
_db.exec('PRAGMA journal_mode=DELETE')
} else {
_db.exec('PRAGMA journal_mode=WAL')
_db.exec('PRAGMA synchronous=NORMAL')
_db.exec('PRAGMA busy_timeout=5000')
_db.exec('PRAGMA foreign_keys=ON')
}
}
return _db
}
// --- JSON fallback backend ---
type JsonData = Record<string, Record<string, Record<string, any>>>
function readJsonStore(): JsonData {
if (!existsSync(JSON_PATH)) return {}
try {
return JSON.parse(readFileSync(JSON_PATH, 'utf-8'))
} catch {
return {}
}
}
function writeJsonStore(data: JsonData): void {
mkdirSync(DB_DIR, { recursive: true })
writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
/**
* Get a record from the JSON store.
* @param table Table name (namespace)
* @param key Primary key
*/
export function jsonGet(table: string, key: string): Record<string, any> | undefined {
const data = readJsonStore()
return data[table]?.[key]
}
/**
* Set a record in the JSON store.
* @param table Table name (namespace)
* @param key Primary key
* @param value Record data
*/
export function jsonSet(table: string, key: string, value: Record<string, any>): void {
const data = readJsonStore()
if (!data[table]) data[table] = {}
data[table][key] = value
writeJsonStore(data)
}
/**
* Get all records from a table in the JSON store.
*/
export function jsonGetAll(table: string): Record<string, Record<string, any>> {
const data = readJsonStore()
return data[table] || {}
}
/**
* Delete a record from the JSON store.
*/
export function jsonDelete(table: string, key: string): void {
const data = readJsonStore()
if (data[table]) {
delete data[table][key]
writeJsonStore(data)
}
}
/**
* Get the storage path for debugging.
*/
export function getStoragePath(): string {
return SQLITE_AVAILABLE ? DB_PATH : JSON_PATH
}
/**
* Close the SQLite database connection.
*/
export function closeDb(): void {
if (_db) {
try {
_db.close()
} catch { /* best-effort */ }
_db = null
}
}
+260
View File
@@ -0,0 +1,260 @@
import Koa from 'koa'
import cors from '@koa/cors'
import bodyParser from '@koa/bodyparser'
import serve from 'koa-static'
import send from 'koa-send'
import os from 'os'
import { resolve } from 'path'
import { mkdir } from 'fs/promises'
import { readFileSync } from 'fs'
import { config, shouldCreateWebUiDataDir } from './config'
import { initLoginLimiter } from './services/login-limiter'
import { bindShutdown } from './services/shutdown'
import { setupTerminalWebSocket } from './routes/hermes/terminal'
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
import { startVersionCheck } from './routes/health'
import { registerRoutes } from './routes'
import { setGroupChatServer } from './routes/hermes/group-chat'
import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { getAgentBridgeManager, startAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
import { logger } from './services/logger'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
declare const __APP_VERSION__: string
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return 'dev' } })()
// Global error handlers
process.on('uncaughtException', (err) => {
console.error('FATAL: Uncaught exception')
console.error(err)
logger.fatal(err, 'Uncaught exception')
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection')
console.error(reason)
logger.error(reason, 'Unhandled rejection')
})
let server: any = null
let servers: any[] = []
let chatRunServer: any = null
let agentBridgeManager: any = null
interface ListenResult {
primary: any
servers: any[]
}
function listen(app: Koa, port: number, host: string): Promise<any> {
return new Promise((resolve, reject) => {
const s = app.listen(port, host)
s.once('listening', () => resolve(s))
s.once('error', reject)
})
}
async function listenWithFallback(app: Koa, port: number, host?: string): Promise<ListenResult> {
const bindHost = host || '0.0.0.0'
console.log(`[bootstrap] listening on ${bindHost}:${port}`)
const primary = await listen(app, port, bindHost)
return { primary, servers: [primary] }
}
/**
* Termux/proot
* proot os.networkInterfaces() errno 13
*/
function safeNetworkInterfaces() {
try {
return os.networkInterfaces()
} catch {
return {}
}
}
function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
async function startRuntimeServicesBeforeListen(): Promise<void> {
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
}
}
function startRuntimeServicesAfterListen(): void {
void (async () => {
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
})()
void (async () => {
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
} catch (err) {
logger.warn(err, '[bootstrap] agent bridge failed to start')
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
}
})()
}
export async function bootstrap() {
console.log(`hermes-web-ui v${APP_VERSION} starting...`)
await mkdir(config.uploadDir, { recursive: true })
if (shouldCreateWebUiDataDir()) {
await mkdir(config.dataDir, { recursive: true })
}
await initLoginLimiter()
try {
const skillInjector = new HermesSkillInjector()
const injectionResult = await skillInjector.injectMissingSkills()
if (injectionResult.injected.length > 0) {
logger.info({
injected: [...new Set(injectionResult.injected)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills injected')
}
if (injectionResult.updated.length > 0) {
logger.info({
updated: [...new Set(injectionResult.updated)],
targetCount: injectionResult.targets.length,
}, '[bootstrap] bundled skills updated')
}
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled skills')
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
}
if (!isDesktopRuntime()) {
await startRuntimeServicesBeforeListen()
}
const app = new Koa()
await new Promise(resolve => setTimeout(resolve, 1000))
// Initialize all web-ui SQLite tables
const { initAllStores } = await import('./db/hermes/init')
// Wait 1 second before initializing stores to ensure all resources are ready
initAllStores()
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('[bootstrap] all stores initialized')
app.use(cors({ origin: config.corsOrigins }))
// Raise JSON/text limits above the default 1mb: profile avatars are posted
// as base64 data URLs (up to ~1MB raw → ~1.37MB base64), which otherwise
// tripped a 413 in the body parser before reaching the handler.
app.use(bodyParser({ encoding: 'utf-8', jsonLimit: '4mb', textLimit: '4mb' }))
console.log('[bootstrap] cors + bodyParser registered')
// Register all routes (handles auth internally)
const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile])
app.use(proxyMiddleware)
console.log('[bootstrap] routes registered')
// SPA fallback
const distDir = resolve(__dirname, '..', 'client')
app.use(serve(distDir))
app.use(async (ctx) => {
if (!ctx.path.startsWith('/api') &&
ctx.path !== '/health' &&
ctx.path !== '/upload' &&
ctx.path !== '/webhook') {
await send(ctx, 'index.html', { root: distDir })
}
})
console.log('[bootstrap] SPA fallback registered')
// Start server using the configured bind host. Default is IPv4 for WSL stability.
const listenResult = await listenWithFallback(app, config.port, config.host)
server = listenResult.primary
servers = listenResult.servers
console.log('[bootstrap] app.listen called')
setupTerminalWebSocket(servers)
setupKanbanEventsWebSocket(servers)
console.log('[bootstrap] terminal + kanban websocket setup')
// Group chat Socket.IO (must be after server is created)
const groupChatServer = new GroupChatServer(servers)
setGroupChatServer(groupChatServer)
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
setChatRunServer(chatRunServer)
chatRunServer.init()
// Session deleter — periodically drain pending session deletes
const { SessionDeleter } = await import('./services/hermes/session-deleter')
const sessionDeleter = SessionDeleter.getInstance()
const activeProfile = process.env.PROFILE || 'default'
sessionDeleter.start(activeProfile)
console.log('[bootstrap] session deleter started, profile=%s', activeProfile)
// Catch-all: destroy upgrade requests not handled by terminal or Socket.IO
servers.forEach((httpServer) => {
httpServer.on('upgrade', (req: any, socket: any) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal' && url.pathname !== '/api/hermes/kanban/events' && !url.pathname.startsWith('/socket.io/')) {
socket.destroy()
}
})
})
const interfaces = safeNetworkInterfaces()
const localIp = Object.values(interfaces).flat().find(i => i?.family === 'IPv4' && !i?.internal)?.address || 'localhost'
console.log(`Server: http://localhost:${config.port} (LAN: http://${localIp}:${config.port})`)
console.log(`Log: ${config.appHome}/logs/server.log`)
logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port)
if (isDesktopRuntime()) {
agentBridgeManager = getAgentBridgeManager()
startRuntimeServicesAfterListen()
}
// Restore group chat agents after server is ready.
groupChatServer.restoreWhenReady()
servers.forEach((httpServer) => {
httpServer.on('error', (err: any) => {
console.error('[bootstrap] server error:', err.code || err.message)
logger.error({ err }, 'Server error')
})
})
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
startVersionCheck()
}
bootstrap().catch((error) => {
console.error('FATAL: Failed to start Hermes Web UI')
console.error(error)
logger.fatal(error, 'Fatal error during bootstrap')
process.exit(1)
})
@@ -0,0 +1,150 @@
/**
* Export Compressor
*
* Compresses session context for export purposes.
* Reuses the LLM summarization logic from ChatContextCompressor
* but does NOT read or write compression snapshots.
* Always forces LLM compression regardless of token count.
* No tail reservation all messages are compressed.
*/
import { logger } from '../../services/logger'
import {
type ChatMessage,
type CompressionConfig,
type CompressedResult,
type SummarizerOptions,
DEFAULT_COMPRESSION_CONFIG,
countTokens,
serializeForSummary,
buildFullPrompt,
buildIncrementalPrompt,
buildConversationHistory,
callSummarizer,
} from './index'
import { getCompressionSnapshot } from '../../db/hermes/compression-snapshot'
export class ExportCompressor {
private config: CompressionConfig
constructor(opts?: { config?: Partial<CompressionConfig> }) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config }
}
async compress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
sessionId?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
const meta: CompressedResult['meta'] = {
totalMessages: total,
compressed: false,
llmCompressed: false,
summaryTokenEstimate: 0,
verbatimCount: 0,
compressedStartIndex: -1,
}
// Read snapshot for incremental context, but never write
const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null
if (snapshot) {
logger.info(
'[export-compressor] session=%s: incremental compress with existing snapshot at index %d',
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, meta, summarizer,
)
}
logger.info(
'[export-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, meta, summarizer)
}
private async incrementalCompress(
messages: ChatMessage[],
snapshot: { summary: string; lastMessageIndex: number },
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const newMessages = messages.slice(lastMessageIndex + 1)
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(newMessages)
const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(newMessages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
summary = previousSummary
}
const summaryText = summary || previousSummary
return {
messages: [{ role: 'user', content: summaryText }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summaryText),
verbatimCount: 0,
},
}
}
private async fullCompress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
if (messages.length === 0) {
return { messages: [], meta }
}
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(messages)
const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget)
const history = buildConversationHistory(messages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] full-llm failed: %s', err.message)
}
if (!summary) {
return { messages, meta }
}
return {
messages: [{ role: 'user', content: summary }],
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(summary),
verbatimCount: 0,
},
}
}
}
@@ -0,0 +1,842 @@
/**
* Chat Context Compressor
*
* Compresses 1:1 chat conversation history before sending to upstream.
* Uses the Hermes structured summary prompt for LLM-based compression.
*
* Algorithm:
* 1. If total tokens < trigger threshold return as-is
* 2. Pre-clean: truncate old tool results (no LLM call)
* 3. Load snapshot from SQLite for incremental update
* 4. Keep last 10 messages verbatim (tail protection by message count)
* 5. Summarize everything before the tail
* 6. Save snapshot: last_message_index = index where compression ends
*/
import { encodingForModel, getEncoding } from 'js-tiktoken'
import { randomUUID } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { resolve } from 'path'
import { logger } from '../../services/logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../../services/hermes/agent-bridge'
import {
getCompressionSnapshot,
saveCompressionSnapshot,
deleteCompressionSnapshot,
} from '../../db/hermes/compression-snapshot'
// ─── Types ───────────────────────────────────────────────
export interface ContentBlock {
type: 'text' | 'image' | 'file'
text?: string
path?: string
source?: { type: string; media_type?: string; data?: string }
}
export interface ChatMessage {
role: string
content: string | ContentBlock[]
tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }>
tool_call_id?: string
name?: string
reasoning_content?: string | null
}
export interface CompressionConfig {
/** Token threshold to trigger compression (default: contextLength / 2) */
triggerTokens: number
/** Summary token target (default: 8000) */
summaryBudget: number
/** Number of earliest messages to keep verbatim (default: 0) */
headMessageCount: number
/** Number of recent messages to keep verbatim (default: 10) */
tailMessageCount: number
/** Timeout for LLM summarization call (default: 300_000ms) */
summarizationTimeoutMs: number
}
export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
triggerTokens: 100_000,
summaryBudget: 8_000,
headMessageCount: 0,
tailMessageCount: 10,
summarizationTimeoutMs: 300_000,
}
export interface CompressedResult {
messages: ChatMessage[]
meta: {
totalMessages: number
compressed: boolean
/** true = actually called LLM to summarize; false = assembled from existing snapshot or returned as-is */
llmCompressed: boolean
summaryTokenEstimate: number
verbatimCount: number
compressedStartIndex: number
}
}
export interface SummarizerOptions {
profile?: string
model?: string | null
provider?: string | null
workerKey?: string
}
const SUMMARIZER_TRIGGER_MESSAGE = 'Generate the context checkpoint summary now.'
const SUMMARIZER_DEBUG_DIR = 'logs/context-compressor'
const SUMMARIZER_DEBUG_FILE = 'summarizer-debug.json'
async function writeSummarizerDebugDump(payload: Record<string, unknown>): Promise<void> {
if (process.env.NODE_ENV !== 'development') return
try {
const debugDir = resolve(process.cwd(), SUMMARIZER_DEBUG_DIR)
await mkdir(debugDir, { recursive: true })
await writeFile(
resolve(debugDir, SUMMARIZER_DEBUG_FILE),
`${JSON.stringify(payload, null, 2)}\n`,
'utf8',
)
} catch (err) {
logger.warn(err, '[context-compressor] failed to write summarizer debug dump')
}
}
// ─── Token counting ─────────────────────────────────────
let _encoder: ReturnType<typeof getEncoding> | null = null
function getEncoder() {
if (!_encoder) {
_encoder = getEncoding('cl100k_base')
}
return _encoder
}
export function countTokens(text: string): number {
try {
return getEncoder().encode(text).length
} catch {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / 4)
}
}
export function countTokensForModel(text: string, model: string): number {
try {
const enc = encodingForModel(model as any)
return enc.encode(text).length
} catch {
return countTokens(text)
}
}
function messageTokenEstimate(message: ChatMessage): number {
if (typeof message.content === 'string') return countTokens(message.content)
if (Array.isArray(message.content)) {
return countTokens(message.content.map(block => {
if (block.type === 'text') return block.text || ''
if (block.type === 'image') return `[Image: ${block.path || ''}]`
if (block.type === 'file') return `[File: ${block.path || ''}]`
return ''
}).join(''))
}
return 0
}
function messagesTokenEstimate(messages: ChatMessage[]): number {
return messages.reduce((sum, message) => sum + messageTokenEstimate(message), 0)
}
function truncateTextToTokenBudget(text: string, tokenBudget: number): string {
if (tokenBudget <= 0 || countTokens(text) <= tokenBudget) return text
let lo = 0
let hi = text.length
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (countTokens(text.slice(0, mid)) <= tokenBudget) lo = mid
else hi = mid - 1
}
return text.slice(0, lo).trimEnd() + '\n\n[Summary truncated to fit context budget]'
}
function enforceCompressedBudget(
messages: ChatMessage[],
triggerTokens: number,
summaryIndex: number,
): ChatMessage[] {
if (triggerTokens <= 0 || messagesTokenEstimate(messages) <= triggerTokens) return messages
const summaryMessage = messages[summaryIndex]
if (!summaryMessage || typeof summaryMessage.content !== 'string') return messages
const summaryOnly = [{ ...summaryMessage }]
if (messagesTokenEstimate(summaryOnly) <= triggerTokens) return summaryOnly
return [{
...summaryMessage,
content: truncateTextToTokenBudget(summaryMessage.content, triggerTokens),
}]
}
// ─── Prompts ────────────────────────────────────────────
export const SUMMARY_PREFIX = `[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted
into the summary below. This is a handoff from a previous context
window treat it as background reference, NOT as active instructions.
Do NOT answer questions or fulfill requests mentioned in this summary;
they were already addressed.
Your current task is identified in the '## Active Task' section of the
summary resume exactly from there.
Respond ONLY to the latest user message
that appears AFTER this summary. The current session state (files,
config, etc.) may reflect work described here avoid repeating it:`
const TEMPLATE_SECTIONS = `Use this exact structure:
## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
task assignment verbatim the exact words they used. If multiple tasks
were requested and only some are done, list only the ones NOT yet completed.
The next assistant must pick up exactly here. Example:
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
If no outstanding task exists, write "None."]
## Goal
[What the user is trying to accomplish overall]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken include tool used, target, and outcome.
Format each as: N. ACTION target outcome [tool: name]
Example:
1. READ config.py:45 found == should be != [tool: read_file]
2. PATCH config.py:45 changed == to != [tool: patch]
3. TEST pytest tests/ 3/50 failed: test_parse, test_validate, test_edge [tool: terminal]
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state include:
- Working directory and branch (if applicable)
- Modified/created files with brief note on each
- Test status (X/Y passing)
- Any running processes or servers
- Environment details that matter]
## In Progress
[Work currently underway what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered include the answer so the next assistant does not re-answer them]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
## Relevant Files
[Files read, modified, or created with brief note on each]
## Remaining Work
[What remains to be done framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation]`
export function buildFullPrompt(contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
Do NOT respond to any questions or requests in the conversation
only output the structured summary.
Do NOT include any preamble, greeting, or prefix.
Create a structured handoff summary for a different assistant that will continue
this conversation after earlier turns are compacted. The next assistant should be
able to understand what happened without re-reading the original turns.
TURNS TO SUMMARIZE:
${contentToSummarize}
${TEMPLATE_SECTIONS}
Target ~${summaryBudget} tokens. Be CONCRETE include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" say exactly what changed.
Write only the summary body. Do not include any preamble or prefix.`
}
export function buildIncrementalPrompt(previousSummary: string, contentToSummarize: string, summaryBudget: number): string {
return `You are a summarization agent creating a context checkpoint.
Your output will be injected as reference material for a DIFFERENT
assistant that continues the conversation.
Do NOT respond to any questions or requests in the conversation
only output the structured summary.
Do NOT include any preamble, greeting, or prefix.
You are updating a context compaction summary. A previous compaction produced the
summary below. New conversation turns have occurred since then and need to be
incorporated.
PREVIOUS SUMMARY:
${previousSummary}
NEW TURNS TO INCORPORATE:
${contentToSummarize}
Update the summary using this exact structure. PRESERVE all existing information
that is still relevant. ADD new completed actions to the numbered list
(continue numbering). Move items from "In Progress" to "Completed Actions" when
done. Move answered questions to "Resolved Questions". Update "Active State"
to reflect current state. Remove information only if it is clearly obsolete.
CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled
request this is the most important field for task continuity.
${TEMPLATE_SECTIONS}
Target ~${summaryBudget} tokens. Be CONCRETE include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" say exactly what changed.
Write only the summary body. Do not include any preamble or prefix.`
}
// ─── Pre-cleaning ───────────────────────────────────────
export function serializeForSummary(messages: ChatMessage[]): string {
const parts: string[] = []
function contentToString(content: string | ContentBlock[]): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content.map(block => {
if (block.type === 'text') return block.text || ''
if (block.type === 'image') return `[Image: ${block.path || ''}]`
if (block.type === 'file') return `[File: ${block.path || ''}]`
return ''
}).join('')
}
return ''
}
for (const msg of messages) {
const role = msg.role === 'tool' ? `[tool:${msg.name || 'unknown'}]` : msg.role
let content = contentToString(msg.content || '')
if (msg.role === 'tool' && content.length > 5500) {
content = content.slice(0, 4000) + '\n... [truncated]\n...' + content.slice(-1500)
}
if (msg.role === 'assistant' && msg.tool_calls?.length) {
const toolsInfo = msg.tool_calls.map(tc => {
let args = tc.function.arguments
if (args.length > 1500) args = args.slice(0, 1500) + '...'
return `[tool_call: ${tc.function.name}(${args})]`
}).join('\n')
parts.push(`${role}: ${toolsInfo}`)
if (content.trim()) parts.push(`${role}: ${content}`)
} else {
parts.push(`${role}: ${content}`)
}
}
return parts.join('\n\n')
}
/**
* Convert messages to conversation history format for LLM API.
* Tool calls are converted to text format within assistant messages.
*/
export function buildConversationHistory(messages: ChatMessage[]): Array<{ role: string; content: string }> {
const result: Array<{ role: string; content: string }> = []
for (const msg of messages) {
if (msg.role === 'tool') {
// Convert tool result to text and append to previous assistant message
const toolText = `[Tool result: ${msg.name || 'unknown'}]\n${(msg.content || '').slice(0, 4000)}${msg.content && msg.content.length > 4000 ? '...' : ''}`
// Find the last assistant message and append to it
const lastAssistant = result.findLast(m => m.role === 'assistant')
if (lastAssistant) {
lastAssistant.content += `\n\n${toolText}`
} else {
// Fallback: create an assistant message
result.push({ role: 'assistant', content: toolText })
}
} else if (msg.role === 'assistant' && msg.tool_calls?.length) {
// Include tool calls in assistant message
const toolsInfo = msg.tool_calls.map(tc => {
let args = tc.function.arguments
if (args.length > 4000) args = args.slice(0, 4000) + '...'
return `[Calling tool: ${tc.function.name} with arguments: ${args}]`
}).join('\n')
const content = msg.content ? `${msg.content}\n\n${toolsInfo}` : toolsInfo
result.push({ role: msg.role, content })
} else if (msg.role === 'user') {
// Handle ContentBlock[] format: { type: 'text', text: '...' } or { type: 'image', path: '...' }
let contentStr = ''
const content = msg.content || ''
if (typeof content === 'string') {
contentStr = content
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
contentStr += block.text || ''
} else if (block.type === 'image') {
contentStr += `[Image: ${block.path || ''}]`
} else if (block.type === 'file') {
contentStr += `[File: ${block.path || ''}]`
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: 'user', content: contentStr })
} else if (msg.role === 'assistant' || msg.role === 'system') {
let contentStr = ''
const content = msg.content
if (typeof content === 'string') {
contentStr = content
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
contentStr += block.text || ''
} else if (block.type === 'image') {
contentStr += `[Image: ${block.path || ''}]`
} else if (block.type === 'file') {
contentStr += `[File: ${block.path || ''}]`
}
}
}
if (contentStr.length > 4000) contentStr = contentStr.slice(0, 4000) + '...'
result.push({ role: msg.role, content: contentStr })
}
// Skip other roles
}
return result
}
export function pruneOldToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
if (messages.length <= keepRecentCount) return messages
const tail = messages.slice(-keepRecentCount)
const head = messages.slice(0, -keepRecentCount)
const pruned = head.map(msg => {
if (msg.role !== 'tool') return msg
let content = ''
if (typeof msg.content === 'string') {
content = msg.content
} else if (Array.isArray(msg.content)) {
content = msg.content.map(block => {
if (block.type === 'text') return block.text || ''
return `[${block.type}]`
}).join('')
}
const preview = content.slice(0, 100).replace(/\n/g, ' ')
const truncated = content.length > 100 ? '...' : ''
return { ...msg, content: `[${msg.name || 'tool'}] ${preview}${truncated}` }
})
return [...pruned, ...tail]
}
function pruneFallbackToolResults(messages: ChatMessage[], keepRecentCount: number): ChatMessage[] {
return pruneOldToolResults(messages, keepRecentCount)
}
// ─── LLM Summarization ──────────────────────────────────
export async function callSummarizer(
upstream: string,
apiKey: string | undefined,
prompt: string,
history: Array<{ role: string; content: string }>,
timeoutMs: number,
previousSummary?: string,
summarizer?: string | SummarizerOptions,
): Promise<string> {
void upstream
void apiKey
const options: SummarizerOptions = typeof summarizer === 'string'
? { profile: summarizer }
: summarizer || {}
const profile = options.profile || 'default'
void history
const convHistory: Array<{ role: string; content: string }> = []
if (previousSummary) {
convHistory.unshift(
{ role: 'user', content: `[Previous summary]\n${previousSummary}` },
{ role: 'assistant', content: 'Understood, I will update the summary.' },
{ role: 'user', content: prompt },
)
} else {
convHistory.unshift({ role: 'user', content: prompt })
}
const bridge = new AgentBridgeClient({ timeoutMs: timeoutMs + 15_000 })
const sessionId = `compress_${Date.now().toString(36)}_${randomUUID().replace(/-/g, '').slice(0, 12)}`
const workerKey = options.workerKey || `${profile}:compression:${sessionId}`
const message = SUMMARIZER_TRIGGER_MESSAGE
await writeSummarizerDebugDump({
writtenAt: new Date().toISOString(),
sessionId,
workerKey,
profile,
model: options.model || null,
provider: options.provider || null,
message,
convHistory,
})
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message,
conversation_history: convHistory,
profile,
worker_key: workerKey,
source: 'api_server',
wait: true,
timeout: Math.ceil(timeoutMs / 1000),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
}, { timeoutMs: timeoutMs + 15_000 })
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
const payload = result.result as any
const output = String(
payload?.final_response ||
result.output ||
'',
).trim()
if (!output) throw new Error('Empty summarization response')
return output
} finally {
await bridge.destroy(sessionId, profile, workerKey).catch(() => undefined)
}
}
// ─── Main Compressor ────────────────────────────────────
export class ChatContextCompressor {
private config: CompressionConfig
constructor(opts?: {
config?: Partial<CompressionConfig>
}) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts?.config }
}
/**
* Assemble and compress conversation history.
*
* Flow:
* 1. Check snapshot if exists, assemble = summary + new messages after snapshot index
* 2. If no snapshot assemble = all messages
* 3. Count tokens of assembled context
* 4. Under threshold return assembled as-is (no LLM call)
* 5. Over threshold LLM compress, keep last N messages, save new snapshot
*/
async compress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
sessionId?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
const makeMeta = (opts: Partial<CompressedResult['meta']> = {}): CompressedResult['meta'] => ({
totalMessages: total,
compressed: false,
llmCompressed: false,
summaryTokenEstimate: 0,
verbatimCount: total,
compressedStartIndex: -1,
...opts,
})
// Check if we have a previous compression snapshot
const snapshot = sessionId ? getCompressionSnapshot(sessionId) : null
if (snapshot && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < messages.length) {
// Has snapshot → incremental compress (merge old summary with new messages)
logger.info(
'[context-compressor] session=%s: incremental compress with snapshot at index %d',
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), summarizer,
)
} else {
if (snapshot && sessionId) {
const fallbackLastMessageIndex = Math.max(-1, messages.length - this.config.tailMessageCount - 1)
logger.warn(
'[context-compressor] session=%s: stale snapshot index %d for %d messages; using summary plus tail from index %d',
sessionId, snapshot.lastMessageIndex, messages.length, fallbackLastMessageIndex,
)
return this.incrementalCompress(
messages,
{ summary: snapshot.summary, lastMessageIndex: fallbackLastMessageIndex },
upstream,
apiKey,
sessionId,
makeMeta(),
summarizer,
)
}
// No snapshot → full compress (compress all messages)
logger.info(
'[context-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), summarizer)
}
}
private async incrementalCompress(
messages: ChatMessage[],
snapshot: { summary: string; lastMessageIndex: number },
upstream: string,
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const total = messages.length
const headCount = Math.min(this.config.headMessageCount, Math.max(0, lastMessageIndex + 1))
const head = messages.slice(0, headCount)
const newMessages = messages.slice(lastMessageIndex + 1)
const tailCount = this.config.tailMessageCount
const previousSummaryMessage: ChatMessage = { role: 'user', content: SUMMARY_PREFIX + '\n\n' + previousSummary }
const assembledWithPrevious = [
...head,
previousSummaryMessage,
...newMessages,
]
const assembledOverBudget = messagesTokenEstimate(assembledWithPrevious) > this.config.triggerTokens
const canKeepTailWindow = newMessages.length > tailCount
// If the new segment itself is too small to split but already over budget,
// fold all new messages into the existing summary instead of preserving them verbatim.
const tailStart = assembledOverBudget && !canKeepTailWindow
? newMessages.length
: Math.max(0, newMessages.length - tailCount)
const toCompress = newMessages.slice(0, tailStart)
const tail = newMessages.slice(tailStart)
if (toCompress.length === 0) {
return {
messages: assembledWithPrevious,
meta: {
...meta,
compressed: true,
llmCompressed: false,
summaryTokenEstimate: countTokens(SUMMARY_PREFIX + previousSummary),
verbatimCount: head.length + newMessages.length,
compressedStartIndex: lastMessageIndex,
},
}
}
logger.info(
'[context-compressor] [incremental-llm] compressing %d of %d new messages, keeping %d tail',
toCompress.length, newMessages.length, tail.length,
)
let summary: string | null = null
try {
const contentToSummarize = serializeForSummary(toCompress)
const prompt = buildIncrementalPrompt(previousSummary, contentToSummarize, this.config.summaryBudget)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, [], this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[context-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] incremental-llm failed: %s — keeping new messages verbatim', err.message)
const fallback = [
...head,
previousSummaryMessage,
...newMessages,
]
const prunedFallback = pruneFallbackToolResults(fallback, this.config.tailMessageCount)
const budgetedFallback = enforceCompressedBudget(prunedFallback, this.config.triggerTokens, head.length)
return {
messages: budgetedFallback,
meta: {
...meta,
compressed: true,
llmCompressed: false,
summaryTokenEstimate: countTokens(SUMMARY_PREFIX + previousSummary),
verbatimCount: budgetedFallback.length === fallback.length ? head.length + newMessages.length : 0,
compressedStartIndex: lastMessageIndex,
},
}
}
let result: ChatMessage[] = [
...head,
{ role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary },
...tail,
]
result = enforceCompressedBudget(result, this.config.triggerTokens, head.length)
const newLastIndex = lastMessageIndex + tailStart
if (sessionId) {
saveCompressionSnapshot(sessionId, summary, newLastIndex, total)
}
return {
messages: result,
meta: {
...meta,
compressed: true,
llmCompressed: true,
summaryTokenEstimate: countTokens(SUMMARY_PREFIX + summary),
verbatimCount: result.length === head.length + 1 + tail.length ? head.length + tail.length : 0,
compressedStartIndex: newLastIndex,
},
}
}
private async fullCompress(
messages: ChatMessage[],
upstream: string,
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
const requestedHeadCount = Math.min(this.config.headMessageCount, total)
const requestedTailCount = this.config.tailMessageCount
const canKeepProtectedWindows = total > requestedHeadCount + requestedTailCount
const headCount = canKeepProtectedWindows ? requestedHeadCount : 0
const tailCount = canKeepProtectedWindows ? requestedTailCount : 0
const tailStart = total - tailCount
const head = messages.slice(0, headCount)
const toCompress = messages.slice(headCount, tailStart)
const tail = messages.slice(tailStart)
logger.info(
'[context-compressor] [full-llm] compressing messages %d-%d, keeping first %d and last %d',
headCount, tailStart - 1, head.length, tail.length,
)
const contentToSummarize = serializeForSummary(toCompress)
const prompt = buildFullPrompt(contentToSummarize, this.config.summaryBudget)
let summary: string | null = null
try {
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, [], this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[context-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] full-llm failed: %s', err.message)
}
if (!summary) {
return { messages: pruneFallbackToolResults(messages, this.config.tailMessageCount), meta }
}
const result: ChatMessage[] = []
result.push(...head)
result.push({ role: 'user', content: SUMMARY_PREFIX + '\n\n' + summary })
if (sessionId) {
saveCompressionSnapshot(sessionId, summary, tailStart - 1, total)
}
result.push(...tail)
const budgetedResult = enforceCompressedBudget(result, this.config.triggerTokens, head.length)
return {
messages: budgetedResult,
meta: {
...meta,
compressed: true,
llmCompressed: !!summary,
summaryTokenEstimate: summary ? countTokens(SUMMARY_PREFIX + summary) : 0,
verbatimCount: budgetedResult.length === result.length ? head.length + tail.length : 0,
compressedStartIndex: tailStart - 1,
},
}
}
/** Remove snapshot for a session (e.g. when session is deleted) */
static invalidateSnapshot(sessionId: string): void {
deleteCompressionSnapshot(sessionId)
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
}
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
}
+267
View File
@@ -0,0 +1,267 @@
/**
* LLM JSON Parsing Utilities
*
* Handles unreliable JSON output from large language models.
* Provides extraction, tolerant parsing, and validation.
*
* Based on production-grade patterns for handling LLM JSON:
* - Extract JSON from text (code blocks, plain objects)
* - Fix common LLM mistakes (single quotes, missing quotes, trailing commas)
* - Validate against schema (zod)
* - Retry on failure
*/
/**
* Extract JSON string from LLM text output.
* Handles: ```json code blocks, plain {...} objects
*/
export function extractJSON(text: string): string {
if (!text || typeof text !== 'string') {
throw new Error('Invalid text: must be non-empty string')
}
const trimmed = text.trim()
// Extract from ```json ... ``` code block
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/)
if (codeBlockMatch) {
return codeBlockMatch[1].trim()
}
// Extract first {...} object (greedy match for nested objects)
const objectMatch = trimmed.match(/\{[\s\S]*\}/)
if (objectMatch) {
return objectMatch[0]
}
// Extract first [...] array (greedy match for nested arrays)
const arrayMatch = trimmed.match(/\[[\s\S]*\]/)
if (arrayMatch) {
return arrayMatch[0]
}
throw new Error('No JSON found in text (no code blocks, objects, or arrays detected)')
}
/**
* Fix common LLM JSON mistakes before parsing.
* Handles: single quotes, unquoted keys, trailing commas, Python booleans/null
*/
export function fixLLMJSON(jsonStr: string): string {
if (!jsonStr || typeof jsonStr !== 'string') {
throw new Error('Invalid JSON string')
}
let fixed = jsonStr
// Fix 1: Python boolean/null literals
fixed = fixed.replace(/\bTrue\b/g, 'true')
fixed = fixed.replace(/\bFalse\b/g, 'false')
fixed = fixed.replace(/\bNone\b/g, 'null')
// Fix 2: Single quotes to double quotes (but be careful with escaped quotes)
// This is a simple replacement - works for most cases but may fail on edge cases
fixed = fixed.replace(/'/g, '"')
// Fix 3: Unquoted object keys (e.g., {name: "value"} -> {"name": "value"})
// Match word followed by : (not already quoted)
fixed = fixed.replace(/(\w+):/g, '"$1":')
// Fix 4: Trailing commas in objects
fixed = fixed.replace(/,\s*}/g, '}')
// Fix 5: Trailing commas in arrays
fixed = fixed.replace(/,\s*]/g, ']')
// Fix 6: Remove extra text before/after JSON (common in LLM outputs)
// Find first { or [ and match to closing bracket
const firstBrace = fixed.indexOf('{')
const firstBracket = fixed.indexOf('[')
if (firstBrace >= 0 && (firstBracket < 0 || firstBrace < firstBracket)) {
// Object first
let depth = 0
let start = firstBrace
let end = -1
for (let i = start; i < fixed.length; i++) {
if (fixed[i] === '{') depth++
else if (fixed[i] === '}') depth--
if (depth === 0) {
end = i + 1
break
}
}
if (end > 0) fixed = fixed.substring(start, end)
} else if (firstBracket >= 0) {
// Array first
let depth = 0
let start = firstBracket
let end = -1
for (let i = start; i < fixed.length; i++) {
if (fixed[i] === '[') depth++
else if (fixed[i] === ']') depth--
if (depth === 0) {
end = i + 1
break
}
}
if (end > 0) fixed = fixed.substring(start, end)
}
return fixed
}
/**
* Parse LLM JSON with fallback attempts.
* Tries: direct parse -> fixed parse -> extracted parse
*/
export function parseLLMJSON(text: string, retries = 3): any {
const errors: Error[] = []
// Attempt 1: Direct parse (already valid JSON)
try {
return JSON.parse(text)
} catch (e) {
errors.push(e as Error)
}
for (let attempt = 0; attempt < retries; attempt++) {
try {
// Attempt 2: Extract and fix
const extracted = extractJSON(text)
const fixed = fixLLMJSON(extracted)
return JSON.parse(fixed)
} catch (e) {
errors.push(e as Error)
// If extraction failed, try fixing the whole text
try {
const fixed = fixLLMJSON(text)
return JSON.parse(fixed)
} catch (e2) {
errors.push(e2 as Error)
}
}
}
// All attempts failed
const error = new Error(`Failed to parse LLM JSON after ${retries + 1} attempts`)
error.cause = errors
throw error
}
/**
* Parse LLM JSON with schema validation (zod).
* Returns validated data or throws validation error.
*/
export async function parseLLMJSONWithSchema<T>(
text: string,
schema: { parse: (data: any) => T },
retries = 3
): Promise<T> {
const data = parseLLMJSON(text, retries)
try {
return schema.parse(data)
} catch (e) {
const error = new Error('LLM JSON schema validation failed')
error.cause = e
throw error
}
}
/**
* Safe parse - returns null on failure instead of throwing.
* Useful for optional JSON fields in LLM responses.
*/
export function safeParseLLMJSON(text: string): any | null {
try {
return parseLLMJSON(text, 1)
} catch {
return null
}
}
/**
* Parse tool_call arguments from LLM output.
* Specifically optimized for OpenAI-style tool calls.
*/
export function parseToolArguments(args: string | object): any {
if (typeof args === 'object') {
return args // Already parsed
}
if (typeof args !== 'string') {
throw new Error('Invalid arguments: must be string or object')
}
const trimmed = args.trim()
// Handle empty object
if (trimmed === '{}' || trimmed === '[]') {
return trimmed === '{}' ? {} : []
}
try {
// Try direct parse first
return JSON.parse(trimmed)
} catch {
// Fall back to LLM JSON parsing
return parseLLMJSON(trimmed, 2)
}
}
/**
* Parse array content from LLM (common in Anthropic-style messages).
* Handles Python-style arrays with thinking/text/tool_use blocks.
*/
export function parseAnthropicContentArray(content: string): Array<{
type: string
text?: string
thinking?: string
id?: string
name?: string
input?: any
}> {
if (!content || typeof content !== 'string') {
return []
}
const trimmed = content.trim()
// Handle double-serialized content: "[{...}]" -> "[{...}]"
let contentToParse = trimmed
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
contentToParse = trimmed.slice(1, -1)
}
if (!contentToParse.startsWith('[') || !contentToParse.endsWith(']')) {
throw new Error('Content is not an array')
}
try {
// Parse with Python-to-JSON conversion
const parsed = JSON.parse(
contentToParse
.replace(/'/g, '"') // Python single quotes
.replace(/True/g, 'true')
.replace(/False/g, 'false')
.replace(/None/g, 'null')
)
if (!Array.isArray(parsed)) {
throw new Error('Parsed content is not an array')
}
return parsed
} catch (e) {
// Fall back to full LLM JSON parsing
const fixed = fixLLMJSON(contentToParse)
const parsed = JSON.parse(fixed)
if (!Array.isArray(parsed)) {
throw new Error('Parsed content is not an array')
}
return parsed
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* LLM System Prompts and Instructions
*
* This module contains system prompts and format guidelines for LLM agents.
* These prompts ensure that AI outputs are correctly rendered by the frontend.
*/
/**
* System prompt for AI output format guidelines
* Add this to your agent's system prompt to ensure proper formatting
*/
export const AI_OUTPUT_FORMAT_GUIDELINES = `
#
使 Markdown
##
- Unix/macOS/WSL使 \`/path/to/file\`,例如 \`/tmp/screenshot.png\`
- Windows使 \`\\\` 转成正斜杠 \`/\`,例如 \`C:/Users/Administrator/Desktop/screenshot.png\`
- Windows Markdown \`<C:/Users/Administrator/Desktop/screenshot.png>\`
- 使 URL
-
##
使 Markdown
\`\`\`
![](/tmp/screenshot.png)
![Sub2API Dashboard](/tmp/sub2api-dashboard.png)
![](<C:/Users/Administrator/Desktop/screenshot.png>)
\`\`\`
##
使 Markdown .mp4.webm.mov 640x480
\`\`\`
[](/tmp/screen-recording.mp4)
[](/tmp/demo.webm)
[2026-05-08 15.19.46](/Users/ekko/Desktop/2026-05-08%2015.19.46.mov)
[2026-05-08 15.19.46](</Users/ekko/Desktop/2026-05-08 15.19.46.mov>)
[Windows ](<C:/Users/Administrator/Desktop/screen recording.mov>)
\`\`\`
\`\`\`
[2026-05-08 15.19.46](/Users/ekko/Desktop/2026-05-08 15.19.46.mov)
![](C:\\Users\\Administrator\\Desktop\\screenshot.png)
\`\`\`
##
使 Markdown
\`\`\`
[](/tmp/monthly-report.pdf)
[](<C:/Users/Administrator/Desktop/monthly-report.pdf>)
\`\`\`
##
"发给我""发送给我""传给我"使
\`\`\`
![](/path/to/image.png)
![Windows ](<C:/Users/Administrator/Desktop/image.png>)
[](/path/to/video.mp4)
[Windows ](<C:/Users/Administrator/Desktop/video.mp4>)
[](/path/to/file.pdf)
[Windows ](<C:/Users/Administrator/Desktop/file.pdf>)
\`\`\`
`;
/**
* Get the complete system prompt with format guidelines
* @param customPrompt - Optional custom system prompt to prepend
* @returns Complete system prompt string
*/
export function getSystemPrompt(customPrompt?: string): string {
const parts: string[] = [];
if (customPrompt) {
parts.push(customPrompt);
}
parts.push(AI_OUTPUT_FORMAT_GUIDELINES);
return parts.join('\n\n');
}
+245
View File
@@ -0,0 +1,245 @@
import type { Context, Next } from 'koa'
import { createHmac, timingSafeEqual } from 'crypto'
import { getToken } from '../services/auth'
import {
findUserById,
listUserProfiles,
touchUserLogin,
userCanAccessProfile,
type UserRecord,
type UserRole,
} from '../db/hermes/users-store'
export interface AuthenticatedUser {
id: number
username: string
role: UserRole
profiles?: string[]
}
export interface RequestProfile {
name: string
}
interface JwtPayload {
sub: string
username: string
role: UserRole
type: 'access'
aud: 'hermes-web-ui'
iat: number
exp: number
}
declare module 'koa' {
interface DefaultState {
user?: AuthenticatedUser
profile?: RequestProfile
serverTokenAuth?: boolean
}
}
const JWT_AUDIENCE = 'hermes-web-ui'
const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30
function base64UrlJson(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url')
}
function sign(input: string, secret: string): string {
return createHmac('sha256', secret).update(input).digest('base64url')
}
function safeEqual(a: string, b: string): boolean {
try {
const left = Buffer.from(a)
const right = Buffer.from(b)
return left.length === right.length && timingSafeEqual(left, right)
} catch {
return false
}
}
async function getJwtSecret(): Promise<string> {
return process.env.AUTH_JWT_SECRET || await getToken()
}
function requestToken(ctx: Context): string {
const auth = ctx.headers.authorization || ''
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim()
return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : ''
}
const SERVER_TOKEN_MEDIA_PATHS = new Set([
'/api/hermes/media/apikey-image-generate',
'/api/hermes/media/grok-image-to-video',
])
async function allowServerTokenForMedia(ctx: Context, token: string): Promise<boolean> {
if (!token || !SERVER_TOKEN_MEDIA_PATHS.has(ctx.path)) return false
const serverToken = await getToken()
if (token !== serverToken) return false
ctx.state.serverTokenAuth = true
return true
}
function isProtectedHttpPath(path: string): boolean {
const lowerPath = path.toLowerCase()
return lowerPath.startsWith('/api') ||
lowerPath.startsWith('/v1') ||
lowerPath.startsWith('/upload')
}
export function signUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>, secret: string, now = Date.now()): string {
const iat = Math.floor(now / 1000)
const payload: JwtPayload = {
sub: String(user.id),
username: user.username,
role: user.role,
type: 'access',
aud: JWT_AUDIENCE,
iat,
exp: iat + DEFAULT_EXPIRES_SECONDS,
}
const header = base64UrlJson({ alg: 'HS256', typ: 'JWT' })
const body = base64UrlJson(payload)
const unsigned = `${header}.${body}`
return `${unsigned}.${sign(unsigned, secret)}`
}
export function verifyUserJwt(token: string, secret: string, now = Date.now()): JwtPayload | null {
const parts = token.split('.')
if (parts.length !== 3) return null
const [header, body, signature] = parts
const expected = sign(`${header}.${body}`, secret)
if (!safeEqual(signature, expected)) return null
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial<JwtPayload>
if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null
if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null
if (Math.floor(now / 1000) >= payload.exp) return null
return payload as JwtPayload
} catch {
return null
}
}
export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'role'>): Promise<string> {
const secret = await getJwtSecret()
return signUserJwt(user, secret)
}
export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser {
const authenticated: AuthenticatedUser = {
id: user.id,
username: user.username,
role: user.role,
}
if (user.role !== 'super_admin') {
authenticated.profiles = listUserProfiles(user.id).map(profile => profile.profile_name)
}
return authenticated
}
export async function authenticateUserToken(token: string): Promise<AuthenticatedUser | null> {
const secret = await getJwtSecret()
const payload = token ? verifyUserJwt(token, secret) : null
if (!payload) return null
const user = findUserById(payload.sub)
if (!user || user.status !== 'active') return null
return toAuthenticatedUser(user)
}
export async function isAuthEnabled(): Promise<boolean> {
await getJwtSecret()
return true
}
export async function requireUserJwt(ctx: Context, next: Next): Promise<void> {
if (!isProtectedHttpPath(ctx.path)) {
await next()
return
}
const secret = await getJwtSecret()
const token = requestToken(ctx)
const payload = token ? verifyUserJwt(token, secret) : null
if (!payload) {
if (await allowServerTokenForMedia(ctx, token)) {
await next()
return
}
ctx.status = 401
ctx.body = { error: 'Unauthorized' }
return
}
const user = findUserById(payload.sub)
if (!user || user.status !== 'active') {
ctx.status = 403
ctx.body = { error: 'User is disabled or does not exist' }
return
}
ctx.state.user = toAuthenticatedUser(user)
touchUserLogin(user.id)
await next()
}
export async function requireSuperAdmin(ctx: Context, next: Next): Promise<void> {
if (ctx.state.user?.role !== 'super_admin') {
ctx.status = 403
ctx.body = { error: 'Super administrator privileges are required' }
return
}
await next()
}
export function resolveRequestedProfile(ctx: Context): string {
if (ctx.path === '/api/hermes/available-models' && typeof ctx.query.profile !== 'string') {
return ''
}
const headerProfile = ctx.get('x-hermes-profile')
const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : ''
const body = ctx.request.body as { profile?: unknown } | undefined
const bodyProfile = typeof body?.profile === 'string' ? body.profile : ''
return (headerProfile || queryProfile || bodyProfile || '').trim()
}
export async function resolveUserProfile(ctx: Context, next: Next): Promise<void> {
const user = ctx.state.user
if (!user) {
await next()
return
}
const profileName = resolveRequestedProfile(ctx)
if (!profileName) {
await next()
return
}
if (user.role !== 'super_admin' && !userCanAccessProfile(user.id, profileName)) {
ctx.status = 403
ctx.body = { error: `Profile "${profileName}" is not available for this user` }
return
}
ctx.state.profile = { name: profileName }
await next()
}
export async function requireUserProfile(ctx: Context, next: Next): Promise<void> {
if (!ctx.state.profile?.name) {
ctx.status = 400
ctx.body = { error: 'Profile is required' }
return
}
await next()
}
export const userAuthMiddleware = [requireUserJwt, resolveUserProfile]
+22
View File
@@ -0,0 +1,22 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/auth'
import { requireSuperAdmin } from '../middleware/user-auth'
// Public routes (no auth required)
export const authPublicRoutes = new Router()
authPublicRoutes.get('/api/auth/status', ctrl.authStatus)
authPublicRoutes.post('/api/auth/login', ctrl.login)
// Protected routes (auth required)
export const authProtectedRoutes = new Router()
authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword)
authProtectedRoutes.get('/api/auth/me', ctrl.currentUser)
authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword)
authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername)
authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword)
authProtectedRoutes.get('/api/auth/users', requireSuperAdmin, ctrl.listManagedUsers)
authProtectedRoutes.post('/api/auth/users', requireSuperAdmin, ctrl.createManagedUser)
authProtectedRoutes.put('/api/auth/users/:id', requireSuperAdmin, ctrl.updateManagedUser)
authProtectedRoutes.delete('/api/auth/users/:id', requireSuperAdmin, ctrl.deleteManagedUser)
authProtectedRoutes.get('/api/auth/locked-ips', ctrl.listLockedIps)
authProtectedRoutes.delete('/api/auth/locked-ips', ctrl.unlockIpHandler)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import { claudeProxyMessages, claudeProxyModels } from '../services/claude-code-proxy'
export const claudeCodeProxyRoutes = new Router()
claudeCodeProxyRoutes.get('/api/claude-code-proxy/:key/v1/models', claudeProxyModels)
claudeCodeProxyRoutes.post('/api/claude-code-proxy/:key/v1/messages', claudeProxyMessages)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import { codexProxyModels, codexProxyResponses } from '../services/codex-proxy'
export const codexProxyRoutes = new Router()
codexProxyRoutes.get('/api/codex-proxy/:key/v1/models', codexProxyModels)
codexProxyRoutes.post('/api/codex-proxy/:key/v1/responses', codexProxyResponses)
@@ -0,0 +1,12 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/coding-agents'
export const codingAgentRoutes = new Router()
codingAgentRoutes.get('/api/coding-agents', ctrl.status)
codingAgentRoutes.post('/api/coding-agents/:id/install', ctrl.install)
codingAgentRoutes.post('/api/coding-agents/:id/launch/prepare', ctrl.prepareLaunch)
codingAgentRoutes.post('/api/coding-agents/:id/launch/native', ctrl.nativeLaunch)
codingAgentRoutes.delete('/api/coding-agents/:id', ctrl.remove)
codingAgentRoutes.get('/api/coding-agents/:id/config-files/:key', ctrl.readConfigFile)
codingAgentRoutes.put('/api/coding-agents/:id/config-files/:key', ctrl.writeConfigFile)
+8
View File
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/health'
export const healthRoutes = new Router()
healthRoutes.get('/health', ctrl.healthCheck)
export { startVersionCheck } from '../controllers/health'
@@ -0,0 +1,11 @@
import type { ChatRunSocket } from '../../services/hermes/run-chat'
let chatRunServer: ChatRunSocket | null = null
export function setChatRunServer(server: ChatRunSocket): void {
chatRunServer = server
}
export function getChatRunServer(): ChatRunSocket | null {
return chatRunServer
}
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/codex-auth'
export const codexAuthRoutes = new Router()
codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start)
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll)
codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/config'
export const configRoutes = new Router()
configRoutes.get('/api/hermes/config', ctrl.getConfig)
configRoutes.put('/api/hermes/config', ctrl.updateConfig)
configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials)
@@ -0,0 +1,10 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/copilot-auth'
export const copilotAuthRoutes = new Router()
copilotAuthRoutes.post('/api/hermes/auth/copilot/start', ctrl.start)
copilotAuthRoutes.get('/api/hermes/auth/copilot/poll/:sessionId', ctrl.poll)
copilotAuthRoutes.get('/api/hermes/auth/copilot/check-token', ctrl.checkToken)
copilotAuthRoutes.post('/api/hermes/auth/copilot/enable', ctrl.enable)
copilotAuthRoutes.post('/api/hermes/auth/copilot/disable', ctrl.disable)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/cron-history'
export const cronHistoryRoutes = new Router()
cronHistoryRoutes.get('/api/cron-history', ctrl.listRuns)
cronHistoryRoutes.get('/api/cron-history/:jobId/:fileName', ctrl.readRun)
@@ -0,0 +1,120 @@
import Router from '@koa/router'
import { basename, extname, isAbsolute } from 'path'
import {
createFileProvider,
localProvider,
isInUploadDir,
validatePath,
resolveHermesPath,
} from '../../services/hermes/file-provider'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
export const downloadRoutes = new Router()
// MIME type mapping for common extensions
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.csv': 'text/csv',
'.md': 'text/markdown',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.py': 'text/x-python',
'.ts': 'text/typescript',
'.tsx': 'text/typescript',
'.rs': 'text/x-rust',
'.go': 'text/x-go',
'.java': 'text/x-java',
'.c': 'text/x-c',
'.cpp': 'text/x-c++',
'.h': 'text/x-c',
'.sh': 'text/x-shellscript',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.toml': 'text/toml',
'.log': 'text/plain',
}
function getMimeType(fileName: string): string {
const ext = extname(fileName).toLowerCase()
return MIME_MAP[ext] || 'application/octet-stream'
}
function requestedProfile(ctx: any): string {
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
}
downloadRoutes.get('/api/hermes/download', async (ctx) => {
const filePath = ctx.query.path as string | undefined
const fileName = ctx.query.name as string | undefined
if (!filePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const profile = requestedProfile(ctx)
// Validate the path first
// Support both absolute and relative paths
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath, profile)
// Choose provider: always use local for upload directory files
let data: Buffer
if (isInUploadDir(validPath)) {
data = await localProvider.readFile(validPath)
} else {
const provider = await createFileProvider(profile)
data = await provider.readFile(validPath)
}
// Determine filename and MIME type
const name = fileName || basename(validPath)
const mime = getMimeType(name)
// Set response headers
ctx.set('Content-Type', mime)
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
ctx.set('Content-Length', String(data.length))
ctx.set('Cache-Control', 'no-cache')
ctx.body = data
} catch (err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
file_too_large: 413,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
})
+299
View File
@@ -0,0 +1,299 @@
import Router from '@koa/router'
import {
createFileProvider,
resolveHermesPath,
isSensitivePath,
MAX_EDIT_SIZE,
} from '../../services/hermes/file-provider'
function requestedProfile(ctx: any): string | undefined {
return ctx.state?.profile?.name
}
function resolveRequestPath(ctx: any, relativePath: string): string {
return resolveHermesPath(relativePath, requestedProfile(ctx))
}
async function createRequestFileProvider(ctx: any) {
return createFileProvider(requestedProfile(ctx))
}
function withAbsolutePath<T extends { path: string }>(ctx: any, entry: T): T & { absolutePath: string } {
return { ...entry, absolutePath: resolveRequestPath(ctx, entry.path) }
}
export const fileRoutes = new Router()
function handleError(ctx: any, err: any) {
const code = err.code || 'unknown'
const statusMap: Record<string, number> = {
missing_path: 400,
invalid_path: 400,
not_found: 404,
ENOENT: 404,
already_exists: 409,
permission_denied: 403,
file_too_large: 413,
not_a_directory: 400,
not_a_file: 400,
unsupported_backend: 501,
backend_error: 502,
backend_timeout: 504,
}
ctx.status = statusMap[code] || 500
ctx.body = { error: err.message, code }
}
// GET /api/hermes/files/list?path=
fileRoutes.get('/api/hermes/files/list', async (ctx) => {
const relativePath = (ctx.query.path as string) || ''
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const entries = await provider.listDir(absPath)
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name)
})
ctx.body = { entries: entries.map(entry => withAbsolutePath(ctx, entry)), path: relativePath, absolutePath: absPath }
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/stat?path=
fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const info = await provider.stat(absPath)
ctx.body = withAbsolutePath(ctx, info)
} catch (err: any) {
handleError(ctx, err)
}
})
// GET /api/hermes/files/read?path=
fileRoutes.get('/api/hermes/files/read', async (ctx) => {
const relativePath = ctx.query.path as string
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
const data = await provider.readFile(absPath)
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'File too large to edit', code: 'file_too_large' }
return
}
ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length }
} catch (err: any) {
handleError(ctx, err)
}
})
// PUT /api/hermes/files/write body: { path, content }
fileRoutes.put('/api/hermes/files/write', async (ctx) => {
const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' }
return
}
try {
const buf = Buffer.from(content || '', 'utf-8')
if (buf.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: 'Content too large', code: 'file_too_large' }
return
}
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.writeFile(absPath, buf)
ctx.body = { ok: true, path: relativePath }
} catch (err: any) {
handleError(ctx, err)
}
})
// DELETE /api/hermes/files/delete body: { path, recursive? }
fileRoutes.delete('/api/hermes/files/delete', async (ctx) => {
const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
if (isSensitivePath(relativePath)) {
ctx.status = 403
ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
if (recursive) {
await provider.deleteDir(absPath)
} else {
await provider.deleteFile(absPath)
}
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/rename body: { oldPath, newPath }
fileRoutes.post('/api/hermes/files/rename', async (ctx) => {
const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string }
if (!oldPath || !newPath) {
ctx.status = 400
ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' }
return
}
if (isSensitivePath(oldPath)) {
ctx.status = 403
ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' }
return
}
try {
const absOld = resolveRequestPath(ctx, oldPath)
const absNew = resolveRequestPath(ctx, newPath)
const provider = await createRequestFileProvider(ctx)
await provider.renameFile(absOld, absNew)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/mkdir body: { path }
fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => {
const { path: relativePath } = ctx.request.body as { path?: string }
if (!relativePath) {
ctx.status = 400
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
return
}
try {
const absPath = resolveRequestPath(ctx, relativePath)
const provider = await createRequestFileProvider(ctx)
await provider.mkDir(absPath)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/copy body: { srcPath, destPath }
fileRoutes.post('/api/hermes/files/copy', async (ctx) => {
const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string }
if (!srcPath || !destPath) {
ctx.status = 400
ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' }
return
}
try {
const absSrc = resolveRequestPath(ctx, srcPath)
const absDest = resolveRequestPath(ctx, destPath)
const provider = await createRequestFileProvider(ctx)
await provider.copyFile(absSrc, absDest)
ctx.body = { ok: true }
} catch (err: any) {
handleError(ctx, err)
}
})
// POST /api/hermes/files/upload?path= (multipart/form-data)
fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
const targetDir = (ctx.query.path as string) || ''
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary', code: 'invalid_request' }
return
}
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const raw = Buffer.concat(chunks)
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const provider = await createRequestFileProvider(ctx)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
const headerBuf = part.subarray(0, headerEnd)
const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
let filename = ''
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
if (filenameStarMatch) {
filename = decodeURIComponent(filenameStarMatch[1])
} else {
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
filename = filenameMatch[1]
}
if (data.length > MAX_EDIT_SIZE) {
ctx.status = 413
ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' }
return
}
const filePath = targetDir ? `${targetDir}/${filename}` : filename
if (isSensitivePath(filePath)) {
ctx.status = 403
ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' }
return
}
const absPath = resolveRequestPath(ctx, filePath)
await provider.writeFile(absPath, data)
results.push({ name: filename, path: filePath })
}
ctx.body = { files: results }
})
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = raw.indexOf(boundary, start)
if (idx === -1) break
if (start > 0) {
const partStart = start + 2
parts.push(raw.subarray(partStart, idx))
}
start = idx + boundary.length
}
return parts
}
@@ -0,0 +1,415 @@
import Router from '@koa/router'
import type { GroupChatServer } from '../../services/hermes/group-chat'
import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing'
export const groupChatRoutes = new Router()
let chatServer: GroupChatServer | null = null
export function setGroupChatServer(server: GroupChatServer) {
chatServer = server
}
export function getGroupChatServer(): GroupChatServer | null {
return chatServer
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function generateInviteCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number }
function sanitizeAgentConnectReason(reason?: string): string {
return (reason || 'agent runtime connection failed')
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
.replace(/(api[_-]?key|token|secret|password)=([^\s]+)/gi, '$1=[REDACTED]')
.split('\n')[0]
.slice(0, 240)
}
function agentConnectFailureBody(profile: string, err: any) {
return {
code: 'PROFILE_AGENT_CONNECT_FAILED',
error: `Failed to connect agent "${profile}" to room`,
profile,
reason: sanitizeAgentConnectReason(err?.message),
}
}
async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) {
const profile = input.profile
const name = input.name || profile
const description = input.description || ''
const invited = input.invited ? 1 : 0
const client = await server.agentClients.createAgent({
agentId,
profile,
name,
description,
invited,
})
try {
await server.agentClients.addAgentToRoom(roomId, client)
return server.getStorage().addRoomAgent(roomId, agentId, profile, name, description, invited)
} catch (err) {
server.agentClients.removeAgentFromRoom(roomId, client.agentId)
throw err
}
}
// Create room
groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { name, inviteCode, agents, compression } = ctx.request.body as {
name?: string
inviteCode?: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
}
if (!name || !inviteCode) {
ctx.status = 400
ctx.body = { error: 'name and inviteCode are required' }
return
}
const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile))
if (reservedAgent) {
ctx.status = 400
ctx.body = { error: '`all` is reserved for @all mentions' }
return
}
const roomId = generateId()
const storage = chatServer.getStorage()
storage.saveRoom(roomId, name, inviteCode, compression)
const addedAgents = []
const agentResults = []
for (const a of agents || []) {
try {
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: a.profile,
name: a.name || a.profile,
description: a.description || '',
invited: a.invited,
})
addedAgents.push(agent)
agentResults.push({ profile: a.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents, agentResults }
})
// Clone room roles/config without copying the conversation context.
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const sourceRoom = chatServer.getStorage().getRoom(ctx.params.roomId)
if (!sourceRoom) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const { name, inviteCode } = ctx.request.body as { name?: string; inviteCode?: string }
const roomId = generateId()
const storage = chatServer.getStorage()
const code = inviteCode?.trim() || generateInviteCode()
storage.saveRoom(roomId, name?.trim() || `${sourceRoom.name} Copy`, code, {
triggerTokens: sourceRoom.triggerTokens,
maxHistoryTokens: sourceRoom.maxHistoryTokens,
tailMessageCount: sourceRoom.tailMessageCount,
})
const addedAgents = []
const agentResults = []
for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) {
try {
const agent = await connectAndPersistRoomAgent(chatServer, roomId, {
profile: sourceAgent.profile,
name: sourceAgent.name,
description: sourceAgent.description,
invited: sourceAgent.invited,
})
addedAgents.push(agent)
agentResults.push({ profile: sourceAgent.profile, ok: true, agent })
} catch (err: any) {
console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`)
agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) })
}
}
const room = storage.getRoom(roomId)
ctx.body = { room, agents: addedAgents, agentResults }
})
// Get room detail and messages
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const room = chatServer.getStorage().getRoom(ctx.params.roomId)
if (!room) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const offset = ctx.query.offset ? Math.max(0, parseInt(ctx.query.offset as string, 10) || 0) : 0
const limit = ctx.query.limit ? Math.max(1, parseInt(ctx.query.limit as string, 10) || 300) : 300
const messages = chatServer.getStorage().getMessages(ctx.params.roomId, limit, offset)
const total = chatServer.getStorage().getMessageCount(ctx.params.roomId)
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
const members = chatServer.getStorage().getRoomMembers(ctx.params.roomId)
ctx.body = { room, messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total }
})
// List rooms
groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const user = ctx.state.user
const storage = chatServer.getStorage()
const rooms = !user || user.role === 'super_admin'
? storage.getAllRooms()
: storage.getRoomsForProfiles(user.profiles || [])
ctx.body = { rooms }
})
// Get room by invite code
groupChatRoutes.get('/api/hermes/group-chat/rooms/join/:code', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const room = chatServer.getStorage().getRoomByInviteCode(ctx.params.code)
if (!room) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
ctx.body = { room }
})
// Update room invite code
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/invite-code', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { inviteCode } = ctx.request.body as { inviteCode?: string }
if (!inviteCode) {
ctx.status = 400
ctx.body = { error: 'inviteCode is required' }
return
}
chatServer.getStorage().updateRoomInviteCode(ctx.params.roomId, inviteCode)
ctx.body = { success: true }
})
// Add agent to room
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const { profile, name, description, invited } = ctx.request.body as { profile?: string; name?: string; description?: string; invited?: boolean }
if (!profile) {
ctx.status = 400
ctx.body = { error: 'profile is required' }
return
}
if (isReservedMentionName(name || profile)) {
ctx.status = 400
ctx.body = { error: '`all` is reserved for @all mentions' }
return
}
// Prevent duplicate agent in same room
const existing = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
if (existing.find(a => a.profile === profile)) {
ctx.status = 409
ctx.body = { error: 'Agent already in room' }
return
}
try {
const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, {
profile,
name: name || profile,
description: description || '',
invited,
})
ctx.body = { agent }
} catch (err: any) {
console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`)
ctx.status = 502
ctx.body = agentConnectFailureBody(profile, err)
}
})
// List agents in room
groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const agents = chatServer.getStorage().getRoomAgents(ctx.params.roomId)
ctx.body = { agents }
})
// Remove agent from room
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
const requestedAgentId = ctx.params.agentId
const storage = chatServer.getStorage()
const agent = storage.getRoomAgent(roomId, requestedAgentId)
if (!agent) {
ctx.status = 404
ctx.body = { error: 'Agent not found' }
return
}
storage.removeRoomMembersForAgent(roomId, agent)
storage.removeRoomAgent(roomId, requestedAgentId)
chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId)
ctx.body = {
success: true,
agents: storage.getRoomAgents(roomId),
members: storage.getRoomMembers(roomId),
}
})
// Delete room
groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
// Disconnect all agents in room
chatServer.agentClients.disconnectRoom(roomId)
// Delete all data
chatServer.getStorage().deleteRoom(roomId)
ctx.body = { success: true }
})
// Clear current room context while keeping members, agents, and room config.
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clear-context', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
if (!chatServer.getStorage().getRoom(roomId)) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
chatServer.getStorage().clearRoomContext(roomId)
chatServer.clearRoomRuntimeState(roomId)
ctx.body = { success: true, room: chatServer.getStorage().getRoom(roomId) }
})
// Update room compression config
groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
const { triggerTokens, maxHistoryTokens, tailMessageCount } = ctx.request.body as {
triggerTokens?: number
maxHistoryTokens?: number
tailMessageCount?: number
}
chatServer.getStorage().updateRoomConfig(roomId, { triggerTokens, maxHistoryTokens, tailMessageCount })
const room = chatServer.getStorage().getRoom(roomId)
ctx.body = { room }
})
// Force compress a room's context
groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx) => {
if (!chatServer) {
ctx.status = 503
ctx.body = { error: 'Group chat not initialized' }
return
}
const roomId = ctx.params.roomId
if (!chatServer.getStorage().getRoom(roomId)) {
ctx.status = 404
ctx.body = { error: 'Room not found' }
return
}
const engine = chatServer.getContextEngine()
if (!engine) {
ctx.status = 503
ctx.body = { error: 'Context engine not available' }
return
}
try {
const result = await engine.forceCompress(roomId)
ctx.body = { success: true, summary: result }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
+13
View File
@@ -0,0 +1,13 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/jobs'
export const jobRoutes = new Router()
jobRoutes.get('/api/hermes/jobs', ctrl.list)
jobRoutes.get('/api/hermes/jobs/:id', ctrl.get)
jobRoutes.post('/api/hermes/jobs', ctrl.create)
jobRoutes.patch('/api/hermes/jobs/:id', ctrl.update)
jobRoutes.delete('/api/hermes/jobs/:id', ctrl.remove)
jobRoutes.post('/api/hermes/jobs/:id/pause', ctrl.pause)
jobRoutes.post('/api/hermes/jobs/:id/resume', ctrl.resume)
jobRoutes.post('/api/hermes/jobs/:id/run', ctrl.run)
@@ -0,0 +1,109 @@
import { WebSocketServer } from 'ws'
import type { WebSocket } from 'ws'
import type { Server as HttpServer, IncomingMessage } from 'http'
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
import { userCanAccessProfile } from '../../db/hermes/users-store'
import { logger } from '../../services/logger'
import * as kanbanCli from '../../services/hermes/hermes-kanban'
interface KanbanEventsRequest extends IncomingMessage {
kanbanBoard?: string
kanbanProfile?: string
}
function sendJson(ws: WebSocket, payload: Record<string, unknown>) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(payload))
}
function streamLines(onLine: (line: string) => void) {
let buffer = ''
return (chunk: Buffer | string) => {
buffer += chunk.toString()
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
}
}
export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[]) {
const wss = new WebSocketServer({ noServer: true })
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
servers.forEach((httpServer) => {
httpServer.on('upgrade', async (req: KanbanEventsRequest, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/kanban/events') return
if (await isAuthEnabled()) {
const token = url.searchParams.get('token') || ''
const user = await authenticateUserToken(token)
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
const profile = (url.searchParams.get('profile') || '').trim()
if (profile && user.role !== 'super_admin' && !userCanAccessProfile(user.id, profile)) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
socket.destroy()
return
}
req.kanbanProfile = profile || undefined
}
try {
req.kanbanBoard = kanbanCli.normalizeBoardSlug(url.searchParams.get('board'))
} catch {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
})
wss.on('connection', (ws, req: KanbanEventsRequest) => {
const board = req.kanbanBoard || 'default'
const child = kanbanCli.watchEvents({ board, interval: 0.5 })
let closed = false
sendJson(ws, { type: 'connected', board })
const closeChild = () => {
if (closed) return
closed = true
if (!child.killed) child.kill()
}
child.stdout?.on('data', streamLines((line) => {
if (line.toLowerCase().startsWith('watching kanban events')) return
sendJson(ws, { type: 'event', board })
}))
child.stderr?.on('data', streamLines((line) => {
sendJson(ws, { type: 'error', board, message: line })
}))
child.on('error', (err) => {
logger.error(err, 'Hermes CLI: kanban watch failed')
sendJson(ws, { type: 'error', board, message: err.message })
if (ws.readyState === ws.OPEN) ws.close()
})
child.on('exit', (code, signal) => {
sendJson(ws, { type: 'stopped', board, code, signal })
if (ws.readyState === ws.OPEN) ws.close()
})
ws.on('close', closeChild)
ws.on('error', closeChild)
})
logger.info('WebSocket ready at /api/hermes/kanban/events (kanban watch bridge)')
}
@@ -0,0 +1,30 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/kanban'
export const kanbanRoutes = new Router()
kanbanRoutes.get('/api/hermes/kanban/boards', ctrl.listBoards)
kanbanRoutes.post('/api/hermes/kanban/boards', ctrl.createBoard)
kanbanRoutes.delete('/api/hermes/kanban/boards/:slug', ctrl.archiveBoard)
kanbanRoutes.get('/api/hermes/kanban/capabilities', ctrl.capabilities)
kanbanRoutes.get('/api/hermes/kanban/stats', ctrl.stats)
kanbanRoutes.get('/api/hermes/kanban/assignees', ctrl.assignees)
kanbanRoutes.get('/api/hermes/kanban/diagnostics', ctrl.diagnostics)
kanbanRoutes.post('/api/hermes/kanban/dispatch', ctrl.dispatch)
kanbanRoutes.get('/api/hermes/kanban/artifact', ctrl.readArtifact)
kanbanRoutes.get('/api/hermes/kanban/search-sessions', ctrl.searchSessions)
kanbanRoutes.post('/api/hermes/kanban/links', ctrl.linkTasks)
kanbanRoutes.delete('/api/hermes/kanban/links', ctrl.unlinkTasks)
kanbanRoutes.post('/api/hermes/kanban/tasks/bulk', ctrl.bulkUpdateTasks)
kanbanRoutes.get('/api/hermes/kanban', ctrl.list)
kanbanRoutes.get('/api/hermes/kanban/:id', ctrl.get)
kanbanRoutes.post('/api/hermes/kanban', ctrl.create)
kanbanRoutes.post('/api/hermes/kanban/complete', ctrl.complete)
kanbanRoutes.post('/api/hermes/kanban/unblock', ctrl.unblock)
kanbanRoutes.post('/api/hermes/kanban/:id/block', ctrl.block)
kanbanRoutes.post('/api/hermes/kanban/:id/assign', ctrl.assign)
kanbanRoutes.post('/api/hermes/kanban/:id/comments', ctrl.addComment)
kanbanRoutes.get('/api/hermes/kanban/:id/log', ctrl.taskLog)
kanbanRoutes.post('/api/hermes/kanban/:id/reclaim', ctrl.reclaim)
kanbanRoutes.post('/api/hermes/kanban/:id/reassign', ctrl.reassign)
kanbanRoutes.post('/api/hermes/kanban/:id/specify', ctrl.specify)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/logs'
export const logRoutes = new Router()
logRoutes.get('/api/hermes/logs', ctrl.list)
logRoutes.get('/api/hermes/logs/:name', ctrl.read)
+12
View File
@@ -0,0 +1,12 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/mcp'
export const mcpRoutes = new Router()
mcpRoutes.get('/api/hermes/mcp/servers', ctrl.listServers)
mcpRoutes.post('/api/hermes/mcp/servers', ctrl.addServer)
mcpRoutes.patch('/api/hermes/mcp/servers/:name', ctrl.updateServer)
mcpRoutes.delete('/api/hermes/mcp/servers/:name', ctrl.removeServer)
mcpRoutes.post('/api/hermes/mcp/servers/:name/test', ctrl.testServer)
mcpRoutes.get('/api/hermes/mcp/tools', ctrl.listTools)
mcpRoutes.post('/api/hermes/mcp/reload', ctrl.reloadMcp)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/media'
export const mediaRoutes = new Router()
mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo)
mediaRoutes.post('/api/hermes/media/apikey-image-generate', ctrl.apiKeyImageGenerate)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/memory'
export const memoryRoutes = new Router()
memoryRoutes.get('/api/hermes/memory', ctrl.get)
memoryRoutes.post('/api/hermes/memory', ctrl.save)
@@ -0,0 +1,19 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/models'
export const modelRoutes = new Router()
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
modelRoutes.post('/api/hermes/provider-models', ctrl.fetchProviderModelList)
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
modelRoutes.put('/api/hermes/model-alias', ctrl.setModelAlias)
modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility)
modelRoutes.put('/api/hermes/custom-model', ctrl.addCustomModel)
modelRoutes.delete('/api/hermes/custom-model', ctrl.removeCustomModel)
// Model context routes
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
modelRoutes.get('/api/hermes/model-context/:provider/:model', ctrl.getModelContext)
modelRoutes.put('/api/hermes/model-context/:provider/:model', ctrl.updateModelContext)
modelRoutes.put('/api/hermes/model-context', ctrl.updateModelContext)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/nous-auth'
export const nousAuthRoutes = new Router()
nousAuthRoutes.post('/api/hermes/auth/nous/start', ctrl.start)
nousAuthRoutes.get('/api/hermes/auth/nous/poll/:sessionId', ctrl.poll)
nousAuthRoutes.get('/api/hermes/auth/nous/status', ctrl.status)
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/performance-monitor'
import { requireSuperAdmin } from '../../middleware/user-auth'
export const performanceMonitorRoutes = new Router()
performanceMonitorRoutes.get('/api/hermes/performance/runtime', requireSuperAdmin, ctrl.runtime)
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/plugins'
export const pluginRoutes = new Router()
pluginRoutes.get('/api/hermes/plugins', ctrl.list)
@@ -0,0 +1,20 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/profiles'
import { requireSuperAdmin } from '../../middleware/user-auth'
export const profileRoutes = new Router()
profileRoutes.get('/api/hermes/profiles', ctrl.list)
profileRoutes.post('/api/hermes/profiles', ctrl.create)
profileRoutes.get('/api/hermes/profiles/runtime-statuses', ctrl.runtimeStatuses)
profileRoutes.get('/api/hermes/profiles/:name/runtime-status', ctrl.runtimeStatus)
profileRoutes.post('/api/hermes/profiles/:name/restart', ctrl.restartProfileRuntime)
profileRoutes.post('/api/hermes/profiles/:name/gateway/restart', ctrl.restartGatewayForProfile)
profileRoutes.put('/api/hermes/profiles/:name/avatar', ctrl.updateAvatar)
profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar)
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
profileRoutes.put('/api/hermes/profiles/active', requireSuperAdmin, ctrl.switchProfile)
profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile)
profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/providers'
export const providerRoutes = new Router()
providerRoutes.post('/api/hermes/config/providers', ctrl.create)
providerRoutes.put('/api/hermes/config/providers/:poolKey', ctrl.update)
providerRoutes.delete('/api/hermes/config/providers/:poolKey', ctrl.remove)
@@ -0,0 +1,295 @@
import type { Context } from 'koa'
import { updateUsage } from '../../db/hermes/usage-store'
let gatewayManager: any = null
export function setGatewayManagerForTest(manager: any): void {
gatewayManager = manager
}
function getGatewayManager() { return gatewayManager }
// --- run_id → session_id mapping (in-memory, ephemeral) ---
const runSessionMap = new Map<string, string>()
export function setRunSession(runId: string, sessionId: string): void {
runSessionMap.set(runId, sessionId)
// Auto-cleanup after 30 minutes
setTimeout(() => runSessionMap.delete(runId), 30 * 60 * 1000)
}
export function getSessionForRun(runId: string): string | undefined {
return runSessionMap.get(runId)
}
// --- Helpers ---
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
const causeCode = String(err?.cause?.code || '')
return (
causeCode === 'ECONNREFUSED' ||
causeCode === 'ECONNRESET' ||
/ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg)
)
}
async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const healthUrl = `${upstream}/health`
while (Date.now() < deadline) {
try {
const res = await fetch(healthUrl, {
method: 'GET',
signal: AbortSignal.timeout(1200),
})
if (res.ok) return true
} catch { }
await new Promise(resolve => setTimeout(resolve, 250))
}
return false
}
/** Resolve profile name from request */
function resolveProfile(ctx: Context): string {
// Use header/query from request, but fall back to authoritative source if not provided
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
if (requestedProfile) {
return requestedProfile
}
// Fallback: read from authoritative source (active_profile file)
try {
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
return getActiveProfileName()
} catch {
return 'default'
}
}
/** Resolve upstream URL for a request based on profile header/query */
function resolveUpstream(ctx: Context): string {
const mgr = getGatewayManager()
if (!mgr) {
throw new Error('GatewayManager not initialized')
}
const profile = resolveProfile(ctx)
if (profile && profile !== 'default') {
return mgr.getUpstream(profile)
}
return mgr.getUpstream()
}
function buildProxyHeaders(ctx: Context, upstream: string): Record<string, string> {
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(ctx.headers)) {
if (value == null) continue
const lower = key.toLowerCase()
if (lower === 'host') {
headers['host'] = new URL(upstream).host
} else if (lower === 'origin' || lower === 'referer' || lower === 'connection' || lower === 'authorization') {
continue
} else {
const v = Array.isArray(value) ? value[0] : value
if (v) headers[key] = v
}
}
const mgr = getGatewayManager()
if (mgr) {
const apiKey = mgr.getApiKey(resolveProfile(ctx))
if (apiKey) {
headers['authorization'] = `Bearer ${apiKey}`
}
}
return headers
}
// --- SSE stream interception ---
const SSE_EVENTS_PATH = /^\/v1\/runs\/([^/]+)\/events$/
/**
* Parse SSE text chunks and extract run.completed events.
* Returns the run_id if a run.completed was found.
*/
function extractRunCompletedFromChunk(chunk: string, profile: string): string | null {
// SSE format: each line is "data: {...}\n\n"
const lines = chunk.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.event === 'run.completed' && data.usage && data.run_id) {
const sessionId = getSessionForRun(data.run_id)
if (sessionId) {
updateUsage(sessionId, {
inputTokens: data.usage.input_tokens,
outputTokens: data.usage.output_tokens,
cacheReadTokens: data.usage.cache_read_tokens,
cacheWriteTokens: data.usage.cache_write_tokens,
reasoningTokens: data.usage.reasoning_tokens,
model: data.model || '',
profile,
})
return data.run_id
}
}
} catch { /* not JSON, skip */ }
}
return null
}
/**
* Stream an SSE response while intercepting run.completed events.
*/
async function streamSSE(ctx: Context, res: Response, profile: string): Promise<void> {
if (!res.body) {
ctx.res.end()
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// Forward raw bytes to client immediately
ctx.res.write(value)
// Also decode for interception
buffer += decoder.decode(value, { stream: true })
// Process complete SSE lines (delimited by double newline)
let newlineIdx: number
while ((newlineIdx = buffer.indexOf('\n\n')) !== -1) {
const eventBlock = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 2)
extractRunCompletedFromChunk(eventBlock, profile)
}
}
// Process remaining buffer
if (buffer.trim()) {
extractRunCompletedFromChunk(buffer, profile)
}
} finally {
ctx.res.end()
}
}
// --- Main proxy function ---
export async function proxy(ctx: Context) {
const profile = resolveProfile(ctx)
let upstream: string
try {
upstream = resolveUpstream(ctx)
} catch (e: any) {
ctx.status = 503
ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } }
return
}
const upstreamPath = ctx.path.replace(/^\/api\/hermes\/v1/, '/v1').replace(/^\/api\/hermes/, '/api')
const params = new URLSearchParams(ctx.search || '')
params.delete('token')
const search = params.toString()
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
const headers = buildProxyHeaders(ctx, upstream)
try {
let body: string | undefined
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
// @koa/bodyparser parses JSON into ctx.request.body but doesn't store rawBody
// by default. Re-serialize the parsed body to get the string form.
const parsed = (ctx as any).request.body
if (typeof parsed === 'string') {
body = parsed
} else if (parsed && typeof parsed === 'object') {
body = JSON.stringify(parsed)
}
}
const requestInit: RequestInit = { method: ctx.req.method, headers, body }
let res: Response
try {
res = await fetch(url, requestInit)
} catch (err: any) {
if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) {
res = await fetch(url, requestInit)
} else {
throw err
}
}
// Set response headers
res.headers.forEach((value, key) => {
const lower = key.toLowerCase()
if (lower !== 'transfer-encoding' && lower !== 'connection') {
ctx.set(key, value)
}
})
ctx.status = res.status
// Intercept POST /v1/runs to capture run_id → session_id mapping
if (ctx.req.method === 'POST' && /\/v1\/runs$/.test(upstreamPath) && body) {
try {
const parsed = JSON.parse(body)
if (parsed.session_id) {
const resBody = await res.text()
ctx.res.write(resBody)
ctx.res.end()
try {
const result = JSON.parse(resBody)
if (result.run_id) {
setRunSession(result.run_id, parsed.session_id)
}
} catch { /* response not JSON, ignore */ }
return
}
} catch { /* body not JSON, fall through to normal stream */ }
// No session_id in body — fall through to normal response handling below
}
// Intercept SSE streams for /v1/runs/{id}/events
const sseMatch = upstreamPath.match(SSE_EVENTS_PATH)
if (sseMatch) {
await streamSSE(ctx, res, profile)
return
}
// Default: pipe response body directly
if (res.body) {
const reader = res.body.getReader()
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
ctx.res.write(value)
}
ctx.res.end()
}
await pump()
} else {
ctx.res.end()
}
} catch (err: any) {
if (!ctx.res.headersSent) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${err.message}` } }
} else {
ctx.res.end()
}
}
}
@@ -0,0 +1,17 @@
import Router from '@koa/router'
import type { Context, Next } from 'koa'
import { proxy } from './proxy-handler'
export const proxyRoutes = new Router()
// Proxy unmatched /api/hermes/* and /v1/* to upstream Hermes API
proxyRoutes.all('/api/hermes/{*any}', proxy)
proxyRoutes.all('/v1/{*any}', proxy)
// Also register as middleware so it works reliably with nested .use()
export async function proxyMiddleware(ctx: Context, next: Next) {
if (ctx.path.startsWith('/api/hermes/') || ctx.path.startsWith('/v1/')) {
return proxy(ctx)
}
await next()
}
@@ -0,0 +1,26 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/sessions'
export const sessionRoutes = new Router()
sessionRoutes.get('/api/hermes/sessions/conversations', ctrl.listConversations)
sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages', ctrl.getConversationMessages)
sessionRoutes.get('/api/hermes/sessions/conversations/:id/messages/paginated', ctrl.getConversationMessagesPaginated)
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
sessionRoutes.get('/api/hermes/sessions/hermes', ctrl.listHermesSessions)
sessionRoutes.get('/api/hermes/sessions/hermes/:id', ctrl.getHermesSession)
sessionRoutes.post('/api/hermes/sessions/hermes/:id/import', ctrl.importHermesSession)
sessionRoutes.get('/api/hermes/search/sessions', ctrl.search)
sessionRoutes.get('/api/hermes/sessions/search', ctrl.search)
sessionRoutes.get('/api/hermes/sessions/usage', ctrl.usageBatch)
sessionRoutes.get('/api/hermes/usage/stats', ctrl.usageStats)
sessionRoutes.get('/api/hermes/sessions/context-length', ctrl.contextLength)
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
sessionRoutes.get('/api/hermes/sessions/:id/export', ctrl.exportSession)
sessionRoutes.get('/api/hermes/sessions/:id/usage', ctrl.usageSingle)
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
sessionRoutes.post('/api/hermes/sessions/batch-delete', ctrl.batchRemove)
sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename)
sessionRoutes.post('/api/hermes/sessions/:id/workspace', ctrl.setWorkspace)
sessionRoutes.post('/api/hermes/sessions/:id/model', ctrl.setModel)
sessionRoutes.get('/api/hermes/workspace/folders', ctrl.listWorkspaceFolders)
@@ -0,0 +1,11 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/skills'
export const skillRoutes = new Router()
skillRoutes.get('/api/hermes/skills', ctrl.list)
skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats)
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_)
@@ -0,0 +1,352 @@
import { WebSocketServer } from 'ws'
import type { Server as HttpServer } from 'http'
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
import { dirname, join, isAbsolute, resolve as resolvePath } from 'path'
import { homedir } from 'os'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider'
import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth'
import { logger } from '../../services/logger'
let pty: any = null
function ensureNodePtySpawnHelperExecutable() {
if (process.platform !== 'darwin') return
try {
const nodePtyRoot = dirname(require.resolve('node-pty/package.json'))
const helperCandidates = [
join(nodePtyRoot, 'build', 'Release', 'spawn-helper'),
join(nodePtyRoot, 'build', 'Debug', 'spawn-helper'),
join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
]
for (const helperPath of helperCandidates) {
if (!existsSync(helperPath)) continue
try {
accessSync(helperPath, fsConstants.X_OK)
} catch {
chmodSync(helperPath, 0o755)
logger.debug('Restored execute bit for node-pty helper: %s', helperPath)
}
}
} catch (err: any) {
logger.warn(err, 'Could not normalize node-pty helper permissions')
}
}
try {
ensureNodePtySpawnHelperExecutable()
// eslint-disable-next-line @typescript-eslint/no-require-imports
pty = require('node-pty')
} catch (err: any) {
logger.warn(err, 'node-pty failed to load, terminal feature disabled')
}
// ─── Shell detection ────────────────────────────────────────────
function findShell(): string {
// Windows 平台:使用 PowerShell
if (process.platform === 'win32') {
return 'powershell.exe'
}
// Unix 平台:使用 SHELL 环境变量,或回退到常用 shells
const candidates = [
process.env.SHELL,
'/bin/zsh',
'/bin/bash',
].filter(Boolean) as string[]
for (const shell of candidates) {
if (existsSync(shell)) return shell
}
return '/bin/bash'
}
function shellName(shell: string): string {
return shell.split('/').pop() || 'shell'
}
export function resolveTerminalCwd(
cfg: Pick<TerminalConfig, 'cwd'> = getTerminalConfig(),
profileDir = getActiveProfileDir(),
): string {
const configured = cfg.cwd?.trim()
const fallback = existsSync(profileDir) ? profileDir : homedir()
if (!configured) return fallback
const cwd = isAbsolute(configured) ? configured : resolvePath(profileDir, configured)
if (!existsSync(cwd)) {
logger.warn({ cwd }, 'Configured terminal cwd does not exist; falling back to Hermes profile directory')
return fallback
}
return cwd
}
// ─── Session types ──────────────────────────────────────────────
interface PtySession {
id: string
pty: { pid: number; onData: (cb: (data: string) => void) => void; onExit: (cb: (e: { exitCode: number }) => void) => void; write: (data: string) => void; kill: (signal?: string) => void; resize: (cols: number, rows: number) => void }
shell: string
pid: number
createdAt: number
}
interface Connection {
sessions: Map<string, PtySession>
activeSessionId: string | null
outputBuffers: Map<string, string[]>
}
// ─── Helpers ────────────────────────────────────────────────────
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function createSession(shell: string): PtySession {
const id = generateId()
let ptyProcess: PtySession['pty']
try {
ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: resolveTerminalCwd(),
})
} catch (err: any) {
throw new Error(`Failed to spawn shell "${shell}": ${err.message}`)
}
const session: PtySession = {
id,
pty: ptyProcess,
shell,
pid: ptyProcess.pid,
createdAt: Date.now(),
}
return session
}
// ─── WebSocket server setup ─────────────────────────────────────
export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) {
if (!pty) {
logger.warn('node-pty not available, skipping terminal WebSocket setup')
return
}
const wss = new WebSocketServer({ noServer: true })
const defaultShell = findShell()
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
servers.forEach((httpServer) => {
httpServer.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '', `http://${req.headers.host}`)
if (url.pathname !== '/api/hermes/terminal') {
return
}
// Auth check
if (await isAuthEnabled()) {
const token = url.searchParams.get('token') || ''
if (!await authenticateUserToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
})
})
wss.on('connection', (ws) => {
const conn: Connection = {
sessions: new Map(),
activeSessionId: null,
outputBuffers: new Map(),
}
// ─── PTY output → WebSocket ──────────────────────────────────
function attachPtyOutput(session: PtySession) {
session.pty.onData((data: string) => {
if (ws.readyState !== ws.OPEN) return
if (conn.activeSessionId === session.id) {
ws.send(data)
} else {
// Buffer output for inactive sessions
let buf = conn.outputBuffers.get(session.id)
if (!buf) {
buf = []
conn.outputBuffers.set(session.id, buf)
}
buf.push(data)
// Cap buffer at 1MB to prevent memory issues
if (buf.length > 5000) {
buf.splice(0, buf.length - 5000)
}
}
})
session.pty.onExit(({ exitCode }: { exitCode: number }) => {
conn.outputBuffers.delete(session.id)
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
}
conn.sessions.delete(session.id)
logger.info('Session %s exited (pid %d, code %d)', session.id, session.pid, exitCode)
})
}
// ─── Message handler ────────────────────────────────────────
ws.on('message', (raw) => {
const msg = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw)
// JSON control message
if (msg.charCodeAt(0) === 0x7B) {
try {
const parsed = JSON.parse(msg)
handleControl(parsed)
} catch {
// Not valid JSON, fall through to raw input
writeRaw(msg)
}
return
}
writeRaw(msg)
})
function writeRaw(data: string) {
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
if (session) {
session.pty.write(data)
}
}
function handleControl(parsed: any) {
switch (parsed.type) {
case 'create': {
const shell = parsed.shell || defaultShell
let session: PtySession
try {
session = createSession(shell)
} catch (err: any) {
ws.send(JSON.stringify({ type: 'error', message: err.message }))
return
}
conn.sessions.set(session.id, session)
conn.activeSessionId = session.id
attachPtyOutput(session)
ws.send(JSON.stringify({
type: 'created',
id: session.id,
pid: session.pid,
shell: shellName(shell),
}))
logger.info('Session created: %s (%s, pid %d)', session.id, shellName(shell), session.pid)
break
}
case 'switch': {
const { sessionId } = parsed
const session = conn.sessions.get(sessionId)
if (!session) {
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }))
return
}
conn.activeSessionId = sessionId
// Send switched first so frontend mounts the correct terminal
ws.send(JSON.stringify({ type: 'switched', id: sessionId }))
// Then flush buffered output for this session
const buf = conn.outputBuffers.get(sessionId)
if (buf && buf.length > 0) {
for (const chunk of buf) {
ws.send(chunk)
}
conn.outputBuffers.delete(sessionId)
}
logger.debug('Switched to session %s', sessionId)
break
}
case 'close': {
const { sessionId } = parsed
const session = conn.sessions.get(sessionId)
if (!session) return
session.pty.kill()
conn.sessions.delete(sessionId)
conn.outputBuffers.delete(sessionId)
if (conn.activeSessionId === sessionId) {
// Auto-switch to the first remaining session
const remaining = Array.from(conn.sessions.keys())
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
}
logger.info('Session closed: %s', sessionId)
break
}
case 'resize': {
const session = conn.activeSessionId ? conn.sessions.get(conn.activeSessionId) : null
if (!session) return
const cols = Math.max(1, parsed.cols || 0)
const rows = Math.max(1, parsed.rows || 0)
try { session.pty.resize(cols, rows) } catch { }
break
}
}
}
// ─── Cleanup ────────────────────────────────────────────────
ws.on('close', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
logger.info('Connection closed, all sessions killed')
})
ws.on('error', () => {
for (const session of Array.from(conn.sessions.values())) {
try { session.pty.kill() } catch { }
}
conn.sessions.clear()
})
// ─── Auto-create first session ──────────────────────────────
let firstSession: PtySession
try {
firstSession = createSession(defaultShell)
} catch (err: any) {
ws.send(JSON.stringify({ type: 'error', message: err.message }))
logger.error(err, 'Failed to create session')
ws.close()
return
}
conn.sessions.set(firstSession.id, firstSession)
conn.activeSessionId = firstSession.id
attachPtyOutput(firstSession)
ws.send(JSON.stringify({
type: 'created',
id: firstSession.id,
pid: firstSession.pid,
shell: shellName(defaultShell),
}))
logger.info('First session created: %s (%s, pid %d)', firstSession.id, shellName(defaultShell), firstSession.pid)
})
logger.info('WebSocket ready at /terminal (shell: %s, transport: node-pty)', defaultShell)
}
+7
View File
@@ -0,0 +1,7 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/tts'
export const ttsRoutes = new Router()
ttsRoutes.post('/api/hermes/tts', ctrl.generate)
ttsRoutes.post('/api/tts/proxy/audio/speech', ctrl.openaiProxy)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/weixin'
export const weixinRoutes = new Router()
weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode)
weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus)
weixinRoutes.post('/api/hermes/weixin/save', ctrl.save)
@@ -0,0 +1,8 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/xai-auth'
export const xaiAuthRoutes = new Router()
xaiAuthRoutes.post('/api/hermes/auth/xai/start', ctrl.start)
xaiAuthRoutes.get('/api/hermes/auth/xai/poll/:sessionId', ctrl.poll)
xaiAuthRoutes.get('/api/hermes/auth/xai/status', ctrl.status)
+89
View File
@@ -0,0 +1,89 @@
import type { Context, Next } from 'koa'
// Shared route modules
import { healthRoutes } from './health'
import { webhookRoutes } from './webhook'
import { uploadRoutes } from './upload'
import { updateRoutes } from './update'
import { authPublicRoutes, authProtectedRoutes } from './auth'
import { codingAgentRoutes } from './coding-agents'
import { claudeCodeProxyRoutes } from './claude-code-proxy'
import { codexProxyRoutes } from './codex-proxy'
// Hermes route modules
import { sessionRoutes } from './hermes/sessions'
import { profileRoutes } from './hermes/profiles'
import { skillRoutes } from './hermes/skills'
import { pluginRoutes } from './hermes/plugins'
import { memoryRoutes } from './hermes/memory'
import { modelRoutes } from './hermes/models'
import { providerRoutes } from './hermes/providers'
import { configRoutes } from './hermes/config'
import { logRoutes } from './hermes/logs'
import { codexAuthRoutes } from './hermes/codex-auth'
import { nousAuthRoutes } from './hermes/nous-auth'
import { copilotAuthRoutes } from './hermes/copilot-auth'
import { xaiAuthRoutes } from './hermes/xai-auth'
import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download'
import { jobRoutes } from './hermes/jobs'
import { cronHistoryRoutes } from './hermes/cron-history'
import { kanbanRoutes } from './hermes/kanban'
import { ttsRoutes } from './hermes/tts'
import { mediaRoutes } from './hermes/media'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
import { performanceMonitorRoutes } from './hermes/performance-monitor'
import { mcpRoutes } from './hermes/mcp'
/**
* Register all routes on the Koa app.
* Public routes are registered first, then auth middleware,
* then all protected routes. Returns the proxy middleware (must be mounted last).
*/
export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, next: Next) => Promise<void>>) {
// --- Public routes (no auth required) ---
app.use(healthRoutes.routes())
app.use(webhookRoutes.routes())
app.use(authPublicRoutes.routes())
app.use(claudeCodeProxyRoutes.routes())
app.use(codexProxyRoutes.routes())
// --- Auth middleware: all routes below require authentication ---
authMiddleware.forEach((middleware) => app.use(middleware))
// --- Protected routes (auth required) ---
app.use(authProtectedRoutes.routes())
app.use(ttsRoutes.routes())
app.use(uploadRoutes.routes())
app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(codingAgentRoutes.routes())
app.use(sessionRoutes.routes())
app.use(profileRoutes.routes())
app.use(skillRoutes.routes())
app.use(pluginRoutes.routes())
app.use(memoryRoutes.routes())
app.use(modelRoutes.routes())
app.use(providerRoutes.routes())
app.use(configRoutes.routes())
app.use(logRoutes.routes())
app.use(codexAuthRoutes.routes())
app.use(nousAuthRoutes.routes())
app.use(copilotAuthRoutes.routes())
app.use(xaiAuthRoutes.routes())
app.use(weixinRoutes.routes())
app.use(groupChatRoutes.routes()) // Must be before proxy
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(downloadRoutes.routes()) // Must be before proxy
app.use(jobRoutes.routes()) // Must be before proxy
app.use(cronHistoryRoutes.routes()) // Must be before proxy
app.use(kanbanRoutes.routes()) // Must be before proxy
app.use(mediaRoutes.routes()) // Must be before proxy
app.use(performanceMonitorRoutes.routes()) // Must be before proxy
app.use(mcpRoutes.routes()) // MCP management
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)
return proxyMiddleware
}
+13
View File
@@ -0,0 +1,13 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/update'
import { requireSuperAdmin } from '../middleware/user-auth'
export const updateRoutes = new Router()
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
updateRoutes.get('/api/hermes/update/preview', requireSuperAdmin, ctrl.previewStatus)
updateRoutes.get('/api/hermes/update/preview/tags', requireSuperAdmin, ctrl.previewTags)
updateRoutes.post('/api/hermes/update/preview/prepare', requireSuperAdmin, ctrl.preparePreview)
updateRoutes.post('/api/hermes/update/preview/install', requireSuperAdmin, ctrl.installPreview)
updateRoutes.post('/api/hermes/update/preview/start', requireSuperAdmin, ctrl.startPreview)
updateRoutes.post('/api/hermes/update/preview/stop', requireSuperAdmin, ctrl.stopPreview)
+6
View File
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/upload'
export const uploadRoutes = new Router()
uploadRoutes.post('/upload', ctrl.handleUpload)
+6
View File
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../controllers/webhook'
export const webhookRoutes = new Router()
webhookRoutes.post('/webhook', ctrl.handleWebhook)
@@ -0,0 +1,62 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { config } from '../config'
const APP_HOME = config.appHome
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
export interface ModelVisibilityRule {
mode: 'all' | 'include'
models: string[]
}
export interface AppConfig {
// Whether GitHub Copilot has been explicitly added by the user in web-ui.
// Default false: even when COPILOT_GITHUB_TOKEN / gh-cli / apps.json can
// resolve a token, the Copilot provider is hidden until the user opts in
// via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui
// owns the provider list, system credentials are merely a fallback source.
copilotEnabled?: boolean
// Web UI-only model display aliases. Keys are provider -> canonical model ID -> display label.
// These aliases never replace the canonical model ID sent back to Hermes.
modelAliases?: Record<string, Record<string, string>>
// Web UI-only manually entered model IDs. Keys are provider -> model IDs.
// This lets users persist provider-supported models that are absent from a
// provider catalog response without changing Hermes Agent config.yaml.
customModels?: Record<string, string[]>
// Web UI-only model picker visibility. This filters what the WUI exposes in
// its sidebar/model pages and never renames or rewrites Hermes canonical
// provider/model IDs. Hermes CLI config remains the upstream source of truth.
modelVisibility?: Record<string, ModelVisibilityRule>
}
let cache: AppConfig | null = null
export async function readAppConfig(): Promise<AppConfig> {
if (cache) return cache
try {
const raw = await readFile(APP_CONFIG_FILE, 'utf-8')
const parsed = JSON.parse(raw) as AppConfig
cache = parsed
return parsed
} catch {
cache = {}
return cache
}
}
export async function writeAppConfig(patch: Partial<AppConfig>): Promise<AppConfig> {
const current = await readAppConfig()
const merged: AppConfig = { ...current, ...patch }
await mkdir(APP_HOME, { recursive: true })
await writeFile(APP_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 })
cache = merged
return merged
}
export function __resetAppConfigCacheForTest(): void {
cache = null
}
+76
View File
@@ -0,0 +1,76 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { randomBytes } from 'crypto'
import { checkToken, recordTokenFailure, extractIp } from './login-limiter'
import { config } from '../config'
const APP_HOME = config.appHome
const TOKEN_FILE = join(APP_HOME, '.token')
function generateToken(): string {
return randomBytes(32).toString('hex')
}
/**
* Get or create the auth token.
*/
export async function getToken(): Promise<string> {
if (process.env.AUTH_TOKEN) {
return process.env.AUTH_TOKEN
}
try {
const token = await readFile(TOKEN_FILE, 'utf-8')
return token.trim()
} catch {
const token = generateToken()
await mkdir(APP_HOME, { recursive: true })
// Only set mode on Unix systems (Windows ignores this)
const options: any = {}
if (process.platform !== 'win32') {
options.mode = 0o600
}
await writeFile(TOKEN_FILE, token + '\n', options)
return token
}
}
/**
* Koa middleware: check Authorization header or query token.
* No path whitelisting applied globally after public routes.
*/
export function requireAuth(token: string | null) {
return async (ctx: any, next: () => Promise<void>) => {
const auth = ctx.headers.authorization || ''
const provided = auth.startsWith('Bearer ')
? auth.slice(7)
: (ctx.query.token as string) || ''
if (!provided || provided !== token) {
// Skip auth for non-API paths (SPA static files)
const lowerPath = ctx.path.toLowerCase()
if (!lowerPath.startsWith('/api') && !lowerPath.startsWith('/v1') && !lowerPath.startsWith('/upload')) {
await next()
return
}
// Check rate limiter for token auth failures (separate IP counters from password login)
const ip = extractIp(ctx)
const result = checkToken(ip)
if (!result.allowed) {
ctx.status = result.status
ctx.set('Content-Type', 'application/json')
ctx.body = { error: 'Too many login attempts, please try again later' }
return
}
recordTokenFailure(ip)
ctx.status = 401
ctx.set('Content-Type', 'application/json')
ctx.body = { error: 'Unauthorized' }
return
}
await next()
}
}
@@ -0,0 +1,995 @@
import { randomBytes } from 'crypto'
import { Readable } from 'stream'
import type { Context } from 'koa'
import { config } from '../config'
export type ApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
export interface ClaudeCodeProxyTargetInput {
provider: string
model: string
baseUrl: string
apiKey: string
apiMode?: ApiMode
}
interface ClaudeCodeProxyTarget extends ClaudeCodeProxyTargetInput {
key: string
routeKey: string
token: string
updatedAt: number
}
const targets = new Map<string, ClaudeCodeProxyTarget>()
const CLAUDE_PROXY_VISIBLE_MODELS = [
'claude-haiku-4-5',
'claude-sonnet-4-6',
'claude-opus-4-7',
]
function targetKey(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return `${provider}\0${model}\0${apiMode}\0${baseUrl}`
}
function routeKeyFor(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return Buffer.from(targetKey(provider, model, apiMode, baseUrl), 'utf-8').toString('base64url')
}
function localProxyBaseUrl(routeKey: string): string {
return `http://127.0.0.1:${config.port}/api/claude-code-proxy/${routeKey}`
}
export function registerClaudeCodeProxyTarget(input: ClaudeCodeProxyTargetInput): { baseUrl: string; token: string; routeKey: string } {
const provider = input.provider.trim()
const model = input.model.trim()
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const apiMode = input.apiMode || 'chat_completions'
const key = targetKey(provider, model, apiMode, baseUrl)
const existing = targets.get(key)
const routeKey = existing?.routeKey || routeKeyFor(provider, model, apiMode, baseUrl)
const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}`
targets.set(key, {
...input,
provider,
model,
baseUrl,
apiMode,
key,
routeKey,
token,
updatedAt: Date.now(),
})
return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey }
}
function findTarget(routeKey: string): ClaudeCodeProxyTarget | null {
for (const target of targets.values()) {
if (target.routeKey === routeKey) return target
}
return null
}
function authToken(ctx: Context): string {
const apiKey = ctx.get('x-api-key').trim()
if (apiKey) return apiKey
const auth = ctx.get('authorization').trim()
const match = auth.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function requireTarget(ctx: Context): ClaudeCodeProxyTarget | null {
const target = findTarget(String(ctx.params.key || ''))
if (!target) {
ctx.status = 404
ctx.body = { type: 'error', error: { type: 'not_found_error', message: 'Claude proxy target not found' } }
return null
}
if (authToken(ctx) !== target.token) {
ctx.status = 401
ctx.body = { type: 'error', error: { type: 'authentication_error', message: 'Invalid Claude proxy token' } }
return null
}
return target
}
function stringifyContent(value: unknown): string {
if (typeof value === 'string') return value
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object' && 'text' in item) return String((item as any).text || '')
return JSON.stringify(item)
}).filter(Boolean).join('\n')
}
if (value == null) return ''
return JSON.stringify(value)
}
function shouldPreserveReasoningContent(target: ClaudeCodeProxyTarget): boolean {
const identifier = `${target.provider} ${target.model} ${target.baseUrl}`.toLowerCase()
return [
'deepseek',
'moonshot',
'kimi',
'mimo',
'xiaomimimo',
].some(part => identifier.includes(part))
}
function anthropicContentToOpenAiMessages(message: any, preserveReasoningContent = false): any[] {
const content = message?.content
if (!Array.isArray(content)) {
return [{ role: message.role, content: stringifyContent(content) }]
}
if (message.role === 'assistant') {
const textParts: string[] = []
const reasoningParts: string[] = []
const toolCalls: any[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking))
if (block?.type === 'redacted_thinking' && preserveReasoningContent) reasoningParts.push('[redacted thinking]')
if (block?.type === 'tool_use') {
toolCalls.push({
id: String(block.id || `tool_${toolCalls.length}`),
type: 'function',
function: {
name: String(block.name || 'tool'),
arguments: JSON.stringify(block.input || {}),
},
})
}
}
const openAiMessage: any = {
role: 'assistant',
content: textParts.join('\n') || null,
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
}
if (preserveReasoningContent && (reasoningParts.length || toolCalls.length)) {
openAiMessage.reasoning_content = reasoningParts.join('\n') || 'tool call'
}
return [openAiMessage]
}
const messages: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_result') {
if (textParts.length) {
messages.push({ role: 'user', content: textParts.splice(0).join('\n') })
}
messages.push({
role: 'tool',
tool_call_id: String(block.tool_use_id || ''),
content: stringifyContent(block.content),
})
}
}
if (textParts.length) messages.push({ role: message.role || 'user', content: textParts.join('\n') })
return messages.length ? messages : [{ role: message.role || 'user', content: '' }]
}
function anthropicToOpenAiChat(body: any, target: ClaudeCodeProxyTarget, stream = false): any {
const messages: any[] = []
const preserveReasoningContent = shouldPreserveReasoningContent(target)
const system = body?.system
if (system) messages.push({ role: 'system', content: stringifyContent(system) })
for (const message of Array.isArray(body?.messages) ? body.messages : []) {
messages.push(...anthropicContentToOpenAiMessages(message, preserveReasoningContent))
}
const tools = Array.isArray(body?.tools)
? body.tools.map((tool: any) => ({
type: 'function',
function: {
name: String(tool.name || ''),
description: String(tool.description || ''),
parameters: tool.input_schema || { type: 'object', properties: {} },
},
})).filter((tool: any) => tool.function.name)
: undefined
return {
model: target.model,
messages,
...(typeof body?.max_tokens === 'number' ? { max_tokens: body.max_tokens } : {}),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(tools?.length ? { tools } : {}),
stream,
}
}
function anthropicToOpenAiResponsesInput(message: any): any[] {
const content = Array.isArray(message?.content) ? message.content : [{ type: 'text', text: stringifyContent(message?.content) }]
if (message.role === 'assistant') {
const items: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_use') {
if (textParts.length) {
items.push({ role: 'assistant', content: textParts.splice(0).join('\n') })
}
items.push({
type: 'function_call',
call_id: String(block.id || `tool_${items.length}`),
name: String(block.name || 'tool'),
arguments: JSON.stringify(block.input || {}),
})
}
}
if (textParts.length) items.push({ role: 'assistant', content: textParts.join('\n') })
return items
}
const items: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_result') {
if (textParts.length) {
items.push({ role: 'user', content: textParts.splice(0).join('\n') })
}
items.push({
type: 'function_call_output',
call_id: String(block.tool_use_id || ''),
output: stringifyContent(block.content),
})
}
}
if (textParts.length) items.push({ role: message.role || 'user', content: textParts.join('\n') })
return items.length ? items : [{ role: message.role || 'user', content: '' }]
}
function anthropicToOpenAiResponses(body: any, target: ClaudeCodeProxyTarget, stream = false): any {
const input: any[] = []
for (const message of Array.isArray(body?.messages) ? body.messages : []) {
input.push(...anthropicToOpenAiResponsesInput(message))
}
const tools = Array.isArray(body?.tools)
? body.tools.map((tool: any) => ({
type: 'function',
name: String(tool.name || ''),
description: String(tool.description || ''),
parameters: tool.input_schema || { type: 'object', properties: {} },
})).filter((tool: any) => tool.name)
: undefined
return {
model: target.model,
input,
...(body?.system ? { instructions: stringifyContent(body.system) } : {}),
...(typeof body?.max_tokens === 'number' ? { max_output_tokens: body.max_tokens } : {}),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(tools?.length ? { tools } : {}),
stream,
store: false,
}
}
function safeJsonParse(value: string): any {
try {
return JSON.parse(value)
} catch {
return {}
}
}
function mapStopReason(reason: string | null | undefined, hasTools: boolean): string {
if (hasTools) return 'tool_use'
if (reason === 'length') return 'max_tokens'
if (reason === 'content_filter') return 'stop_sequence'
return 'end_turn'
}
function openAiToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any {
const choice = data?.choices?.[0] || {}
const message = choice.message || {}
const content: any[] = []
if (shouldPreserveReasoningContent(target) && message.reasoning_content) {
content.push({ type: 'thinking', thinking: String(message.reasoning_content) })
}
if (message.content) content.push({ type: 'text', text: String(message.content) })
for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
content.push({
type: 'tool_use',
id: String(call.id || `toolu_${content.length}`),
name: String(call.function?.name || 'tool'),
input: safeJsonParse(String(call.function?.arguments || '{}')),
})
}
const hasTools = content.some(block => block.type === 'tool_use')
return {
id: String(data?.id || `msg_${Date.now()}`),
type: 'message',
role: 'assistant',
model: target.model,
content,
stop_reason: mapStopReason(choice.finish_reason, hasTools),
stop_sequence: null,
usage: {
input_tokens: Number(data?.usage?.prompt_tokens || 0),
output_tokens: Number(data?.usage?.completion_tokens || 0),
},
}
}
function sseEvent(event: string, data: any): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
function anthropicMessageToSse(message: any): string {
let output = ''
output += sseEvent('message_start', {
type: 'message_start',
message: { ...message, content: [], stop_reason: null, usage: { input_tokens: message.usage.input_tokens, output_tokens: 0 } },
})
message.content.forEach((block: any, index: number) => {
if (block.type === 'text') {
output += sseEvent('content_block_start', { type: 'content_block_start', index, content_block: { type: 'text', text: '' } })
if (block.text) output += sseEvent('content_block_delta', { type: 'content_block_delta', index, delta: { type: 'text_delta', text: block.text } })
output += sseEvent('content_block_stop', { type: 'content_block_stop', index })
} else if (block.type === 'tool_use') {
output += sseEvent('content_block_start', {
type: 'content_block_start',
index,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
output += sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input || {}) },
})
output += sseEvent('content_block_stop', { type: 'content_block_stop', index })
}
})
output += sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: message.stop_reason, stop_sequence: null },
usage: { output_tokens: message.usage.output_tokens },
})
output += sseEvent('message_stop', { type: 'message_stop' })
return output
}
function anthropicMessagesUrl(target: ClaudeCodeProxyTarget): string {
if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages`
return `${target.baseUrl}/v1/messages`
}
async function readProviderJson(res: Response): Promise<any> {
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
}
function throwProviderError(res: Response, data: any): never {
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
function anthropicRequestBody(body: any, target: ClaudeCodeProxyTarget): any {
return {
...body,
model: target.model,
}
}
async function callAnthropicMessages(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicRequestBody(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
async function callOpenAiChat(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiChat(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
async function callOpenAiResponses(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'codex_responses') {
const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/responses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiResponses(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
function responseOutputText(item: any): string {
if (item?.type === 'output_text') return String(item.text || '')
if (item?.type === 'message' && Array.isArray(item.content)) {
return item.content
.map((part: any) => {
if (part?.type === 'output_text' || part?.type === 'text') return String(part.text || '')
return ''
})
.filter(Boolean)
.join('')
}
return ''
}
function openAiResponsesToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any {
const content: any[] = []
const output = Array.isArray(data?.output) ? data.output : []
for (const item of output) {
const text = responseOutputText(item)
if (text) content.push({ type: 'text', text })
if (item?.type === 'function_call') {
content.push({
type: 'tool_use',
id: String(item.call_id || item.id || `toolu_${content.length}`),
name: String(item.name || 'tool'),
input: safeJsonParse(String(item.arguments || '{}')),
})
}
}
if (!content.length && data?.output_text) {
content.push({ type: 'text', text: String(data.output_text) })
}
const hasTools = content.some(block => block.type === 'tool_use')
return {
id: String(data?.id || `msg_${Date.now()}`),
type: 'message',
role: 'assistant',
model: target.model,
content,
stop_reason: hasTools ? 'tool_use' : (data?.status === 'incomplete' ? 'max_tokens' : 'end_turn'),
stop_sequence: null,
usage: {
input_tokens: Number(data?.usage?.input_tokens || 0),
output_tokens: Number(data?.usage?.output_tokens || 0),
},
}
}
function getReadableStream(res: Response): AsyncIterable<Uint8Array> {
const body = res.body
if (!body) throw new Error('Provider returned an empty stream')
return body as any
}
function parseOpenAiSse(buffer: string): { events: string[]; rest: string } {
const events: string[] = []
let cursor = 0
while (true) {
const index = buffer.indexOf('\n\n', cursor)
if (index < 0) break
events.push(buffer.slice(cursor, index))
cursor = index + 2
}
return { events, rest: buffer.slice(cursor) }
}
function extractSseData(event: string): string[] {
return event
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trimStart())
}
function openAiFinishToAnthropic(finishReason: string | null | undefined, sawTool: boolean): string {
return mapStopReason(finishReason, sawTool)
}
async function openAiChatToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiChat(body, target, true)),
})
if (!res.ok) {
let data: any
const text = await res.text()
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
const messageId = `msg_${Date.now()}`
let buffer = ''
let thinkingBlockIndex: number | null = null
let thinkingBlockStopped = false
let textBlockStarted = false
let textBlockStopped = false
let textBlockIndex: number | null = null
let nextIndex = 0
let stopReason: string | null = null
let outputTokens = 0
const toolBlocks = new Map<number, { blockIndex: number; id: string; name: string; started: boolean }>()
yield sseEvent('message_start', {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: target.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})
const ensureThinkingBlock = function* () {
if (thinkingBlockIndex == null) {
thinkingBlockIndex = nextIndex++
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: thinkingBlockIndex,
content_block: { type: 'thinking', thinking: '' },
})
}
return thinkingBlockIndex
}
const stopThinkingBlock = function* () {
if (thinkingBlockIndex != null && !thinkingBlockStopped) {
thinkingBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingBlockIndex })
}
}
const ensureTextBlock = function* () {
if (!textBlockStarted) {
textBlockStarted = true
textBlockIndex = nextIndex
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: textBlockIndex,
content_block: { type: 'text', text: '' },
})
nextIndex += 1
}
return textBlockIndex ?? 0
}
const ensureToolBlock = function* (toolIndex: number, id?: string, name?: string) {
let block = toolBlocks.get(toolIndex)
if (!block) {
block = {
blockIndex: nextIndex++,
id: id || `toolu_${toolIndex}`,
name: name || 'tool',
started: false,
}
toolBlocks.set(toolIndex, block)
} else {
if (id) block.id = id
if (name) block.name = name
}
if (!block.started && block.name) {
block.started = true
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: block.blockIndex,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
}
return block
}
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
const choice = data?.choices?.[0]
if (!choice) continue
const delta = choice.delta || {}
if (shouldPreserveReasoningContent(target) && typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
const index = yield* ensureThinkingBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'thinking_delta', thinking: delta.reasoning_content },
})
}
if (typeof delta.content === 'string' && delta.content) {
yield* stopThinkingBlock()
const index = yield* ensureTextBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: delta.content },
})
}
for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
yield* stopThinkingBlock()
if (textBlockStarted && !textBlockStopped) {
textBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 })
}
const toolIndex = Number(toolCall.index || 0)
const block = yield* ensureToolBlock(
toolIndex,
toolCall.id ? String(toolCall.id) : undefined,
toolCall.function?.name ? String(toolCall.function.name) : undefined,
)
const argsDelta = toolCall.function?.arguments
if (typeof argsDelta === 'string' && argsDelta) {
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: argsDelta },
})
}
}
if (choice.finish_reason) stopReason = String(choice.finish_reason)
if (data?.usage?.completion_tokens) outputTokens = Number(data.usage.completion_tokens)
}
}
}
yield* stopThinkingBlock()
if (textBlockStarted && !textBlockStopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 })
}
for (const block of toolBlocks.values()) {
if (block.started) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
yield sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null },
usage: { output_tokens: outputTokens },
})
yield sseEvent('message_stop', { type: 'message_stop' })
}
return Readable.from(generate())
}
async function anthropicMessagesSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicRequestBody(body, target)),
})
if (!res.ok) {
const data = await readProviderJson(res)
throwProviderError(res, data)
}
return Readable.from(getReadableStream(res))
}
async function openAiResponsesToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'codex_responses') {
const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/responses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiResponses(body, target, true)),
})
if (!res.ok) {
let data: any
const text = await res.text()
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
let messageId = `msg_${Date.now()}`
let buffer = ''
let textBlockIndex: number | null = null
let textBlockStopped = false
let nextIndex = 0
let stopReason: string | null = null
let outputTokens = 0
const toolBlocks = new Map<string, { blockIndex: number; id: string; name: string; argsDeltaSeen: boolean; stopped: boolean }>()
yield sseEvent('message_start', {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: target.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})
const ensureTextBlock = function* () {
if (textBlockIndex == null) {
textBlockIndex = nextIndex++
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: textBlockIndex,
content_block: { type: 'text', text: '' },
})
}
return textBlockIndex
}
const ensureToolBlock = function* (key: string, id?: string, name?: string) {
let block = toolBlocks.get(key)
if (!block) {
block = {
blockIndex: nextIndex++,
id: id || key || `toolu_${toolBlocks.size}`,
name: name || 'tool',
argsDeltaSeen: false,
stopped: false,
}
toolBlocks.set(key, block)
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: block.blockIndex,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
} else {
if (id) block.id = id
if (name && block.name === 'tool') block.name = name
}
return block
}
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
const eventType = data?.type
if (eventType === 'response.created') {
messageId = String(data?.response?.id || messageId)
}
if (eventType === 'response.output_text.delta') {
const deltaText = String(data?.delta || data?.text || '')
if (deltaText) {
const index = yield* ensureTextBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: deltaText },
})
}
}
if (eventType === 'response.output_text.done' && textBlockIndex != null && !textBlockStopped) {
textBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex })
}
if (eventType === 'response.output_item.added') {
const item = data?.item || data?.output_item
if (item?.type === 'function_call') {
const key = String(item.call_id || item.id || data.output_index || toolBlocks.size)
yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined)
}
}
if (eventType === 'response.function_call_arguments.delta') {
const key = String(data.call_id || data.item_id || data.output_index || toolBlocks.size)
const block = yield* ensureToolBlock(key)
const argsDelta = String(data.delta || '')
if (argsDelta) {
block.argsDeltaSeen = true
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: argsDelta },
})
}
}
if (eventType === 'response.output_item.done') {
const item = data?.item || data?.output_item
if (item?.type === 'function_call') {
const key = String(item.call_id || item.id || data.output_index || toolBlocks.size)
const block = yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined)
const args = String(item.arguments || '')
if (args && !block.argsDeltaSeen) {
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: args },
})
}
if (!block.stopped) {
block.stopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
}
if (eventType === 'response.completed') {
const response = data?.response || data
outputTokens = Number(response?.usage?.output_tokens || outputTokens)
stopReason = response?.status === 'incomplete' ? 'length' : 'stop'
}
}
}
}
if (textBlockIndex != null && !textBlockStopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex })
}
for (const block of toolBlocks.values()) {
if (!block.stopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
yield sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null },
usage: { output_tokens: outputTokens },
})
yield sseEvent('message_stop', { type: 'message_stop' })
}
return Readable.from(generate())
}
export async function claudeProxyModels(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
const ids = [...new Set([...CLAUDE_PROXY_VISIBLE_MODELS, target.model])]
ctx.body = {
data: ids.map(id => ({
type: 'model',
id,
display_name: id,
created_at: '2026-01-01T00:00:00Z',
})),
has_more: false,
first_id: ids[0],
last_id: ids[ids.length - 1],
}
}
export async function claudeProxyMessages(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
try {
const requestBody = ctx.request.body || {}
if ((requestBody as any).stream === true) {
const stream = target.apiMode === 'anthropic_messages'
? await anthropicMessagesSseStream(target, requestBody)
: target.apiMode === 'codex_responses'
? await openAiResponsesToAnthropicSseStream(target, requestBody)
: await openAiChatToAnthropicSseStream(target, requestBody)
ctx.set('Content-Type', 'text/event-stream; charset=utf-8')
ctx.set('Cache-Control', 'no-cache')
ctx.body = stream
} else {
const message = target.apiMode === 'anthropic_messages'
? await callAnthropicMessages(target, requestBody)
: target.apiMode === 'codex_responses'
? openAiResponsesToAnthropicMessage(await callOpenAiResponses(target, requestBody), target)
: openAiToAnthropicMessage(await callOpenAiChat(target, requestBody), target)
ctx.body = message
}
} catch (err: any) {
ctx.status = err.status || 502
ctx.body = {
type: 'error',
error: {
type: 'api_error',
message: err?.message || 'Claude proxy request failed',
provider_error: err?.providerError,
},
}
}
}
+908
View File
@@ -0,0 +1,908 @@
import { randomBytes } from 'crypto'
import { Readable } from 'stream'
import type { Context } from 'koa'
import { config } from '../config'
import type { ApiMode } from './claude-code-proxy'
export interface CodexProxyTargetInput {
profile: string
provider: string
model: string
baseUrl: string
apiKey: string
apiMode?: ApiMode
}
interface CodexProxyTarget extends CodexProxyTargetInput {
key: string
routeKey: string
token: string
updatedAt: number
}
const targets = new Map<string, CodexProxyTarget>()
function targetKey(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return `${profile}\0${provider}\0${model}\0${apiMode}\0${baseUrl}`
}
function routeKeyFor(profile: string, provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return Buffer.from(targetKey(profile, provider, model, apiMode, baseUrl), 'utf-8').toString('base64url')
}
function localProxyBaseUrl(routeKey: string): string {
return `http://127.0.0.1:${config.port}/api/codex-proxy/${routeKey}/v1`
}
export function registerCodexProxyTarget(input: CodexProxyTargetInput): { baseUrl: string; token: string; routeKey: string } {
const profile = input.profile.trim()
const provider = input.provider.trim()
const model = input.model.trim()
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const apiMode = input.apiMode || 'chat_completions'
const key = targetKey(profile, provider, model, apiMode, baseUrl)
const existing = targets.get(key)
const routeKey = existing?.routeKey || routeKeyFor(profile, provider, model, apiMode, baseUrl)
const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}`
targets.set(key, {
...input,
profile,
provider,
model,
baseUrl,
apiMode,
key,
routeKey,
token,
updatedAt: Date.now(),
})
return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey }
}
function findTarget(routeKey: string): CodexProxyTarget | null {
for (const target of targets.values()) {
if (target.routeKey === routeKey) return target
}
return null
}
function authToken(ctx: Context): string {
const apiKey = ctx.get('x-api-key').trim()
if (apiKey) return apiKey
const auth = ctx.get('authorization').trim()
const match = auth.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function requireTarget(ctx: Context): CodexProxyTarget | null {
const target = findTarget(String(ctx.params.key || ''))
if (!target) {
ctx.status = 404
ctx.body = { error: { type: 'not_found_error', message: 'Codex proxy target not found' } }
return null
}
if (authToken(ctx) !== target.token) {
ctx.status = 401
ctx.body = { error: { type: 'authentication_error', message: 'Invalid Codex proxy token' } }
return null
}
return target
}
function stringifyContent(value: unknown): string {
if (typeof value === 'string') return value
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object') {
const block = item as any
if (typeof block.text === 'string') return block.text
if (typeof block.output === 'string') return block.output
}
return JSON.stringify(item)
}).filter(Boolean).join('\n')
}
if (value == null) return ''
return JSON.stringify(value)
}
function responseContentToText(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return stringifyContent(content)
return content.map((part: any) => {
if (typeof part === 'string') return part
if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') {
return String(part.text || '')
}
return stringifyContent(part)
}).filter(Boolean).join('\n')
}
function responsesInputToChatMessages(body: any): any[] {
const messages: any[] = []
if (body?.instructions) {
messages.push({ role: 'system', content: stringifyContent(body.instructions) })
}
const input = body?.input
if (typeof input === 'string') {
messages.push({ role: 'user', content: input })
return messages
}
for (const item of Array.isArray(input) ? input : []) {
if (!item || typeof item !== 'object') continue
if (item.type === 'function_call') {
const callId = String(item.call_id || item.id || `call_${messages.length}`)
messages.push({
role: 'assistant',
content: null,
tool_calls: [{
id: callId,
type: 'function',
function: {
name: String(item.name || 'tool'),
arguments: String(item.arguments || '{}'),
},
}],
})
continue
}
if (item.type === 'function_call_output') {
messages.push({
role: 'tool',
tool_call_id: String(item.call_id || ''),
content: stringifyContent(item.output),
})
continue
}
if (item.role) {
messages.push({
role: chatRoleForResponsesRole(item.role),
content: responseContentToText(item.content),
})
}
}
return messages.length ? messages : [{ role: 'user', content: '' }]
}
function chatRoleForResponsesRole(role: unknown): string {
const value = String(role || '').trim()
if (value === 'developer') return 'system'
if (value === 'system' || value === 'user' || value === 'assistant' || value === 'tool') return value
return 'user'
}
function responsesToolsToChatTools(tools: unknown): any[] | undefined {
if (!Array.isArray(tools)) return undefined
const mapped = tools.map((tool: any) => {
if (tool?.type !== 'function') return null
return {
type: 'function',
function: {
name: String(tool.name || ''),
description: String(tool.description || ''),
parameters: tool.parameters || { type: 'object', properties: {} },
},
}
}).filter((tool: any) => tool?.function?.name)
return mapped.length ? mapped : undefined
}
function responsesToOpenAiChat(body: any, target: CodexProxyTarget, stream = false): any {
const tools = responsesToolsToChatTools(body?.tools)
return {
model: target.model,
messages: responsesInputToChatMessages(body),
...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : {}),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}),
...(tools?.length ? { tools } : {}),
stream,
}
}
function responsesRoleToAnthropicRole(role: unknown): 'user' | 'assistant' {
return String(role || '') === 'assistant' ? 'assistant' : 'user'
}
function responsesContentToAnthropicContent(content: unknown, role: 'user' | 'assistant'): any[] {
const parts = Array.isArray(content) ? content : [{ type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyContent(content) }]
const mapped = parts.map((part: any) => {
if (typeof part === 'string') return { type: 'text', text: part }
if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') {
return { type: 'text', text: String(part.text || '') }
}
return null
}).filter(Boolean)
return mapped.length ? mapped : [{ type: 'text', text: '' }]
}
function responsesInputToAnthropicMessages(body: any): any[] {
const messages: any[] = []
const input = body?.input
if (typeof input === 'string') return [{ role: 'user', content: [{ type: 'text', text: input }] }]
for (const item of Array.isArray(input) ? input : []) {
if (!item || typeof item !== 'object') continue
if (item.type === 'function_call') {
messages.push({
role: 'assistant',
content: [{
type: 'tool_use',
id: String(item.call_id || item.id || `toolu_${messages.length}`),
name: String(item.name || 'tool'),
input: safeJsonParse(String(item.arguments || '{}')),
}],
})
continue
}
if (item.type === 'function_call_output') {
messages.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: String(item.call_id || ''),
content: stringifyContent(item.output),
}],
})
continue
}
if (item.role) {
const role = responsesRoleToAnthropicRole(item.role)
messages.push({
role,
content: responsesContentToAnthropicContent(item.content, role),
})
}
}
return messages.length ? messages : [{ role: 'user', content: [{ type: 'text', text: '' }] }]
}
function responsesToolsToAnthropicTools(tools: unknown): any[] | undefined {
if (!Array.isArray(tools)) return undefined
const mapped = tools.map((tool: any) => {
if (tool?.type !== 'function') return null
return {
name: String(tool.name || ''),
description: String(tool.description || ''),
input_schema: tool.parameters || { type: 'object', properties: {} },
}
}).filter((tool: any) => tool?.name)
return mapped.length ? mapped : undefined
}
function responsesToAnthropicMessages(body: any, target: CodexProxyTarget, stream = false): any {
const tools = responsesToolsToAnthropicTools(body?.tools)
return {
model: target.model,
messages: responsesInputToAnthropicMessages(body),
...(body?.instructions ? { system: stringifyContent(body.instructions) } : {}),
...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : { max_tokens: 4096 }),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}),
...(tools?.length ? { tools } : {}),
stream,
}
}
function chatCompletionsUrl(target: CodexProxyTarget): string {
if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/chat/completions`
return `${target.baseUrl}/v1/chat/completions`
}
function anthropicMessagesUrl(target: CodexProxyTarget): string {
if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages`
return `${target.baseUrl}/v1/messages`
}
async function readProviderJson(res: Response): Promise<any> {
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
}
function throwProviderError(res: Response, data: any): never {
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
function responseId(data: any): string {
return String(data?.id || `resp_${Date.now()}`)
}
function usageFromChat(data: any) {
return {
input_tokens: Number(data?.usage?.prompt_tokens || 0),
output_tokens: Number(data?.usage?.completion_tokens || 0),
total_tokens: Number(data?.usage?.total_tokens || 0),
}
}
function usageFromAnthropic(data: any) {
const inputTokens = Number(data?.usage?.input_tokens || 0)
const outputTokens = Number(data?.usage?.output_tokens || 0)
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
}
}
function openAiChatToResponses(data: any, target: CodexProxyTarget): any {
const choice = data?.choices?.[0] || {}
const message = choice.message || {}
const output: any[] = []
if (message.content) {
output.push({
type: 'message',
id: `msg_${responseId(data)}`,
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text: String(message.content), annotations: [] }],
})
}
for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
output.push({
type: 'function_call',
id: String(call.id || `fc_${output.length}`),
call_id: String(call.id || `call_${output.length}`),
name: String(call.function?.name || 'tool'),
arguments: String(call.function?.arguments || '{}'),
})
}
return {
id: responseId(data),
object: 'response',
created_at: Number(data?.created || Math.floor(Date.now() / 1000)),
status: 'completed',
model: target.model,
output,
usage: usageFromChat(data),
}
}
function anthropicMessageToResponses(data: any, target: CodexProxyTarget): any {
const output: any[] = []
const textParts: string[] = []
for (const block of Array.isArray(data?.content) ? data.content : []) {
if (block?.type === 'text' && block.text) textParts.push(String(block.text))
if (block?.type === 'tool_use') {
output.push({
type: 'function_call',
id: String(block.id || `fc_${output.length}`),
call_id: String(block.id || `call_${output.length}`),
name: String(block.name || 'tool'),
arguments: JSON.stringify(block.input || {}),
})
}
}
if (textParts.length) {
output.unshift({
type: 'message',
id: `msg_${responseId(data)}`,
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text: textParts.join('\n'), annotations: [] }],
})
}
return {
id: responseId(data),
object: 'response',
created_at: Math.floor(Date.now() / 1000),
status: 'completed',
model: target.model,
output,
usage: usageFromAnthropic(data),
}
}
async function callOpenAiChat(target: CodexProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(chatCompletionsUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(responsesToOpenAiChat(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
async function callAnthropicMessages(target: CodexProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(responsesToAnthropicMessages(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
function sseEvent(event: string, data: any): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
function safeJsonParse(value: string): any {
try {
return JSON.parse(value)
} catch {
return {}
}
}
function getReadableStream(res: Response): AsyncIterable<Uint8Array> {
const body = res.body
if (!body) throw new Error('Provider returned an empty stream')
return body as any
}
function parseOpenAiSse(buffer: string): { events: string[]; rest: string } {
const events: string[] = []
let cursor = 0
while (true) {
const index = buffer.indexOf('\n\n', cursor)
if (index < 0) break
events.push(buffer.slice(cursor, index))
cursor = index + 2
}
return { events, rest: buffer.slice(cursor) }
}
function extractSseData(event: string): string[] {
return event
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trimStart())
}
async function openAiChatToResponsesSseStream(target: CodexProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(chatCompletionsUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(responsesToOpenAiChat(body, target, true)),
})
if (!res.ok) {
const data = await readProviderJson(res)
throwProviderError(res, data)
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
const id = `resp_${Date.now()}`
const messageId = `msg_${id}`
let buffer = ''
let textStarted = false
let text = ''
const toolCalls = new Map<number, { id: string; name: string; arguments: string; added: boolean }>()
yield sseEvent('response.created', {
type: 'response.created',
response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] },
})
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
const choice = data?.choices?.[0]
if (!choice) continue
const delta = choice.delta || {}
if (typeof delta.content === 'string' && delta.content) {
if (!textStarted) {
textStarted = true
yield sseEvent('response.output_item.added', {
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'message',
id: messageId,
status: 'in_progress',
role: 'assistant',
content: [],
},
})
yield sseEvent('response.content_part.added', {
type: 'response.content_part.added',
item_id: messageId,
output_index: 0,
content_index: 0,
part: { type: 'output_text', text: '', annotations: [] },
})
}
text += delta.content
yield sseEvent('response.output_text.delta', {
type: 'response.output_text.delta',
item_id: messageId,
output_index: 0,
content_index: 0,
delta: delta.content,
})
}
for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
const index = Number(toolCall.index || 0)
let call = toolCalls.get(index)
if (!call) {
call = {
id: String(toolCall.id || `call_${index}`),
name: String(toolCall.function?.name || 'tool'),
arguments: '',
added: false,
}
toolCalls.set(index, call)
}
if (toolCall.id) call.id = String(toolCall.id)
if (toolCall.function?.name) call.name = String(toolCall.function.name)
if (!call.added && call.name) {
call.added = true
yield sseEvent('response.output_item.added', {
type: 'response.output_item.added',
output_index: textStarted ? index + 1 : index,
item: {
type: 'function_call',
id: call.id,
call_id: call.id,
name: call.name,
arguments: '',
},
})
}
const argsDelta = toolCall.function?.arguments
if (typeof argsDelta === 'string' && argsDelta) {
call.arguments += argsDelta
yield sseEvent('response.function_call_arguments.delta', {
type: 'response.function_call_arguments.delta',
item_id: call.id,
output_index: textStarted ? index + 1 : index,
delta: argsDelta,
})
}
}
}
}
}
const output: any[] = []
if (textStarted) {
const messageItem = {
type: 'message',
id: messageId,
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text, annotations: [] }],
}
output.push(messageItem)
yield sseEvent('response.output_text.done', {
type: 'response.output_text.done',
item_id: messageId,
output_index: 0,
content_index: 0,
text,
})
yield sseEvent('response.content_part.done', {
type: 'response.content_part.done',
item_id: messageId,
output_index: 0,
content_index: 0,
part: { type: 'output_text', text, annotations: [] },
})
yield sseEvent('response.output_item.done', {
type: 'response.output_item.done',
output_index: 0,
item: messageItem,
})
}
for (const [index, call] of toolCalls.entries()) {
const outputIndex = textStarted ? index + 1 : index
const callItem = {
type: 'function_call',
id: call.id,
call_id: call.id,
name: call.name,
arguments: call.arguments || '{}',
}
output.push(callItem)
yield sseEvent('response.output_item.done', {
type: 'response.output_item.done',
output_index: outputIndex,
item: callItem,
})
}
yield sseEvent('response.completed', {
type: 'response.completed',
response: {
id,
object: 'response',
status: 'completed',
model: target.model,
output,
},
})
}
return Readable.from(generate())
}
function extractSseEventName(event: string): string {
return event
.split(/\r?\n/)
.find(line => line.startsWith('event:'))
?.slice(6)
.trim() || ''
}
async function anthropicMessagesToResponsesSseStream(target: CodexProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(responsesToAnthropicMessages(body, target, true)),
})
if (!res.ok) {
const data = await readProviderJson(res)
throwProviderError(res, data)
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
let id = `resp_${Date.now()}`
let messageId = `msg_${id}`
let buffer = ''
let textStarted = false
let text = ''
const toolBlocks = new Map<number, { id: string; name: string; arguments: string; added: boolean }>()
yield sseEvent('response.created', {
type: 'response.created',
response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] },
})
const ensureText = function* () {
if (!textStarted) {
textStarted = true
yield sseEvent('response.output_item.added', {
type: 'response.output_item.added',
output_index: 0,
item: { type: 'message', id: messageId, status: 'in_progress', role: 'assistant', content: [] },
})
yield sseEvent('response.content_part.added', {
type: 'response.content_part.added',
item_id: messageId,
output_index: 0,
content_index: 0,
part: { type: 'output_text', text: '', annotations: [] },
})
}
}
const ensureTool = function* (index: number, idValue?: string, name?: string) {
let block = toolBlocks.get(index)
if (!block) {
block = { id: idValue || `toolu_${index}`, name: name || 'tool', arguments: '', added: false }
toolBlocks.set(index, block)
}
if (idValue) block.id = idValue
if (name) block.name = name
if (!block.added) {
block.added = true
yield sseEvent('response.output_item.added', {
type: 'response.output_item.added',
output_index: textStarted ? index + 1 : index,
item: { type: 'function_call', id: block.id, call_id: block.id, name: block.name, arguments: '' },
})
}
return block
}
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
const eventName = extractSseEventName(event)
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
if (eventName === 'message_start' || data?.type === 'message_start') {
id = String(data?.message?.id || id)
messageId = `msg_${id}`
}
if (eventName === 'content_block_start' || data?.type === 'content_block_start') {
const contentBlock = data?.content_block || {}
if (contentBlock.type === 'tool_use') {
yield* ensureTool(Number(data.index || 0), String(contentBlock.id || ''), String(contentBlock.name || 'tool'))
}
}
if (eventName === 'content_block_delta' || data?.type === 'content_block_delta') {
const delta = data?.delta || {}
if (delta.type === 'text_delta' && delta.text) {
yield* ensureText()
text += String(delta.text)
yield sseEvent('response.output_text.delta', {
type: 'response.output_text.delta',
item_id: messageId,
output_index: 0,
content_index: 0,
delta: String(delta.text),
})
}
if (delta.type === 'input_json_delta' && delta.partial_json) {
const index = Number(data.index || 0)
const block = yield* ensureTool(index)
const argsDelta = String(delta.partial_json)
block.arguments += argsDelta
yield sseEvent('response.function_call_arguments.delta', {
type: 'response.function_call_arguments.delta',
item_id: block.id,
output_index: textStarted ? index + 1 : index,
delta: argsDelta,
})
}
}
}
}
}
const output: any[] = []
if (textStarted) {
const messageItem = {
type: 'message',
id: messageId,
status: 'completed',
role: 'assistant',
content: [{ type: 'output_text', text, annotations: [] }],
}
output.push(messageItem)
yield sseEvent('response.output_text.done', {
type: 'response.output_text.done',
item_id: messageId,
output_index: 0,
content_index: 0,
text,
})
yield sseEvent('response.content_part.done', {
type: 'response.content_part.done',
item_id: messageId,
output_index: 0,
content_index: 0,
part: { type: 'output_text', text, annotations: [] },
})
yield sseEvent('response.output_item.done', {
type: 'response.output_item.done',
output_index: 0,
item: messageItem,
})
}
for (const [index, block] of toolBlocks.entries()) {
const outputIndex = textStarted ? index + 1 : index
const item = {
type: 'function_call',
id: block.id,
call_id: block.id,
name: block.name,
arguments: block.arguments || '{}',
}
output.push(item)
yield sseEvent('response.output_item.done', {
type: 'response.output_item.done',
output_index: outputIndex,
item,
})
}
yield sseEvent('response.completed', {
type: 'response.completed',
response: { id, object: 'response', status: 'completed', model: target.model, output },
})
}
return Readable.from(generate())
}
export async function codexProxyResponses(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
try {
const requestBody = ctx.request.body || {}
if ((requestBody as any).stream === true) {
const stream = target.apiMode === 'anthropic_messages'
? await anthropicMessagesToResponsesSseStream(target, requestBody)
: await openAiChatToResponsesSseStream(target, requestBody)
ctx.set('Content-Type', 'text/event-stream; charset=utf-8')
ctx.set('Cache-Control', 'no-cache')
ctx.body = stream
} else {
ctx.body = target.apiMode === 'anthropic_messages'
? anthropicMessageToResponses(await callAnthropicMessages(target, requestBody), target)
: openAiChatToResponses(await callOpenAiChat(target, requestBody), target)
}
} catch (err: any) {
ctx.status = err.status || 502
ctx.body = {
error: {
type: 'api_error',
message: err?.message || 'Codex proxy request failed',
provider_error: err?.providerError,
},
}
}
}
export async function codexProxyModels(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
ctx.body = {
object: 'list',
data: [{
id: target.model,
object: 'model',
created: 0,
owned_by: target.provider,
}],
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
import { readFile, chmod } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getProfileDir } from './hermes/hermes-profile'
import { logger } from './logger'
import { safeFileStore } from './safe-file-store'
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
export const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
'fun-codex': { api_key_env: '', base_url_env: '' },
'fun-claude': { api_key_env: '', base_url_env: '' },
lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' },
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: 'OPENROUTER_BASE_URL' },
'glm-coding-plan': { api_key_env: '', base_url_env: '' },
zai: { api_key_env: 'GLM_API_KEY', base_url_env: 'GLM_BASE_URL' },
'kimi-coding': { api_key_env: 'KIMI_API_KEY', base_url_env: 'KIMI_BASE_URL' },
'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' },
minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: 'MINIMAX_BASE_URL' },
'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: 'MINIMAX_CN_BASE_URL' },
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: 'DASHSCOPE_BASE_URL' },
'alibaba-coding-plan': { api_key_env: 'ALIBABA_CODING_PLAN_API_KEY', base_url_env: 'ALIBABA_CODING_PLAN_BASE_URL' },
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: 'ANTHROPIC_BASE_URL' },
xai: { api_key_env: 'XAI_API_KEY', base_url_env: 'XAI_BASE_URL' },
'xai-oauth': { api_key_env: '', base_url_env: '' },
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: 'XIAOMI_BASE_URL' },
'xiaomi-token-plan': { api_key_env: 'XIAOMI_TOKEN_PLAN_API_KEY', base_url_env: 'XIAOMI_TOKEN_PLAN_BASE_URL' },
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: 'GEMINI_BASE_URL' },
kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: 'KILOCODE_BASE_URL' },
'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: 'AI_GATEWAY_BASE_URL' },
cliproxyapi: { api_key_env: '', base_url_env: '' },
'opencode-zen': { api_key_env: 'OPENCODE_ZEN_API_KEY', base_url_env: 'OPENCODE_ZEN_BASE_URL' },
'opencode-go': { api_key_env: 'OPENCODE_GO_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' },
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' },
nvidia: { api_key_env: 'NVIDIA_API_KEY', base_url_env: 'NVIDIA_BASE_URL' },
novita: { api_key_env: 'NOVITA_API_KEY', base_url_env: 'NOVITA_BASE_URL' },
gmi: { api_key_env: 'GMI_API_KEY', base_url_env: 'GMI_BASE_URL' },
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: 'ARCEE_BASE_URL' },
stepfun: { api_key_env: 'STEPFUN_API_KEY', base_url_env: 'STEPFUN_BASE_URL' },
'ollama-cloud': { api_key_env: 'OLLAMA_API_KEY', base_url_env: 'OLLAMA_BASE_URL' },
nous: { api_key_env: '', base_url_env: '' },
'openai-codex': { api_key_env: '', base_url_env: '' },
'openai-api': { api_key_env: 'OPENAI_API_KEY', base_url_env: 'OPENAI_BASE_URL' },
copilot: { api_key_env: 'GITHUB_TOKEN', base_url_env: '' },
longcat: { api_key_env: 'LONGCAT_API_KEY', base_url_env: 'LONGCAT_BASE_URL' },
'tencent-tokenhub': { api_key_env: 'TENCENT_TOKENHUB_API_KEY', base_url_env: 'TOKENHUB_BASE_URL' },
}
// --- Types ---
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
export interface SkillInfo {
name: string
description: string
enabled: boolean
source?: SkillSource
}
export interface SkillCategory {
name: string
description: string
skills: SkillInfo[]
}
export interface ModelInfo {
id: string
label: string
}
export interface ModelGroup {
provider: string
models: ModelInfo[]
}
// --- Config YAML helpers ---
const configPath = () => getActiveConfigPath()
const configPathForProfile = (profile: string) => join(getProfileDir(profile), 'config.yaml')
const envPathForProfile = (profile: string) => join(getProfileDir(profile), '.env')
export async function readConfigYaml(): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPath())
}
export async function readConfigYamlForProfile(profile: string): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPathForProfile(profile))
}
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
await safeFileStore.writeYaml(configPath(), config, { backup: true })
}
export async function updateConfigYaml<T = void>(
updater: (config: Record<string, any>) => Record<string, any> | { data: Record<string, any>; result: T; write?: boolean } | Promise<Record<string, any> | { data: Record<string, any>; result: T; write?: boolean }>,
): Promise<T | undefined> {
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
}
export async function updateConfigYamlForProfile<T = void>(
profile: string,
updater: (config: Record<string, any>) => Record<string, any> | { data: Record<string, any>; result: T; write?: boolean } | Promise<Record<string, any> | { data: Record<string, any>; result: T; write?: boolean }>,
): Promise<T | undefined> {
return safeFileStore.updateYaml(configPathForProfile(profile), updater, { backup: true })
}
export function stripLegacyApiServerGatewayConfig(config: Record<string, any>): { config: Record<string, any>; changed: boolean } {
if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) {
return { config, changed: false }
}
if (config.platforms.api_server !== undefined) {
delete config.platforms.api_server
if (Object.keys(config.platforms).length === 0) delete config.platforms
return { config, changed: true }
}
return { config, changed: false }
}
// --- .env helpers ---
function assertValidEnvKey(key: string): void {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
throw new Error(`Invalid .env key: ${JSON.stringify(key)}`)
}
}
async function saveEnvValueAtPath(envPath: string, key: string, value: string): Promise<void> {
assertValidEnvKey(key)
await safeFileStore.updateText(envPath, (raw) => {
const remove = !value
const lines = raw.split('\n')
let found = false
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) {
if (!remove) result.push(`${key}=${value}`)
found = true
} else {
const eqIdx = trimmed.indexOf('=')
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
if (!remove) result.push(`${key}=${value}`)
found = true
} else {
result.push(line)
}
}
}
if (!found && !remove) {
result.push(`${key}=${value}`)
}
return result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
})
try { await chmod(envPath, 0o600) } catch { /* ignore */ }
}
export async function saveEnvValue(key: string, value: string): Promise<void> {
await saveEnvValueAtPath(getActiveEnvPath(), key, value)
}
export async function saveEnvValueForProfile(profile: string, key: string, value: string): Promise<void> {
await saveEnvValueAtPath(envPathForProfile(profile), key, value)
}
// --- File helpers ---
export async function safeReadFile(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8')
} catch {
return null
}
}
export async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
try {
const s = await stat(filePath)
return { mtime: Math.round(s.mtimeMs) }
} catch {
return null
}
}
// --- Skill helpers ---
export function extractDescription(content: string): string {
const lines = content.split('\n')
let inFrontmatter = false
let bodyStarted = false
for (const line of lines) {
if (!bodyStarted && line.trim() === '---') {
if (!inFrontmatter) {
inFrontmatter = true
continue
} else {
inFrontmatter = false
bodyStarted = true
continue
}
}
if (inFrontmatter) continue
if (line.trim() === '') continue
if (line.startsWith('#')) continue
return line.trim().slice(0, 80)
}
return ''
}
export async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
const result: { path: string; name: string }[] = []
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return result
}
for (const entry of entries) {
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isDirectory()) {
result.push(...await listFilesRecursive(join(dir, entry.name), relPath))
} else {
result.push({ path: relPath, name: entry.name })
}
}
return result
}
// --- Provider model helpers ---
export async function fetchProviderModels(baseUrl: string, apiKey: string, freeOnly = false): Promise<string[]> {
const base = baseUrl.replace(/\/+$/, '')
const modelsUrl = /\/v\d+\/?$/.test(base) ? `${base}/models` : `${base}/v1/models`
try {
const res = await fetch(modelsUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(8000),
})
if (!res.ok) {
logger.warn('available-models %s returned %d', modelsUrl, res.status)
return []
}
const data = await res.json() as { data?: Array<{ id: string }> }
if (!Array.isArray(data.data)) {
logger.warn('available-models %s returned unexpected format', modelsUrl)
return []
}
let models = data.data.map(m => m.id)
if (freeOnly) models = models.filter(m => m.endsWith(':free'))
return models.sort()
} catch (err: any) {
logger.error(err, 'available-models %s failed', modelsUrl)
return []
}
}
export function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
let defaultModel = ''
const groups: ModelGroup[] = []
// 1. Extract current model
const modelSection = config.model
if (typeof modelSection === 'object' && modelSection !== null) {
defaultModel = String(modelSection.default || '').trim()
} else if (typeof modelSection === 'string') {
defaultModel = modelSection.trim()
}
// 2. Extract custom_providers section
const customProviders = config.custom_providers
if (Array.isArray(customProviders)) {
const customModels: ModelInfo[] = []
for (const entry of customProviders) {
if (entry && typeof entry === 'object') {
const cName = String(entry.name || '').trim()
const cModel = String(entry.model || '').trim()
if (cName && cModel) {
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
}
}
}
if (customModels.length > 0) {
groups.push({ provider: 'Custom', models: customModels })
}
}
return { default: defaultModel, groups }
}
// --- Profile directory helper ---
export const getHermesDir = () => getActiveProfileDir()
@@ -0,0 +1,59 @@
import { readFile, writeFile, mkdir, unlink } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { scryptSync, randomBytes } from 'node:crypto'
import { config } from '../config'
const APP_HOME = config.appHome
const CREDENTIALS_FILE = join(APP_HOME, '.credentials')
export interface Credentials {
username: string
password_hash: string
salt: string
created_at: number
}
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
function hashPassword(password: string, salt: string): string {
return scryptSync(password, salt, 64, SCRYPT_OPTIONS).toString('hex')
}
export async function getCredentials(): Promise<Credentials | null> {
try {
const data = await readFile(CREDENTIALS_FILE, 'utf-8')
return JSON.parse(data)
} catch {
return null
}
}
export async function setCredentials(username: string, password: string): Promise<Credentials> {
const salt = randomBytes(16).toString('hex')
const password_hash = hashPassword(password, salt)
const cred: Credentials = { username, password_hash, salt, created_at: Date.now() }
await mkdir(APP_HOME, { recursive: true })
await writeFile(CREDENTIALS_FILE, JSON.stringify(cred, null, 2), { mode: 0o600 })
return cred
}
export async function deleteCredentials(): Promise<void> {
try {
await unlink(CREDENTIALS_FILE)
} catch {
// File may not exist
}
}
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
const cred = await getCredentials()
if (!cred) return false
if (cred.username !== username) return false
const computed = hashPassword(password, cred.salt)
return computed === cred.password_hash
}
export function credentialsFileExists(): boolean {
return existsSync(CREDENTIALS_FILE)
}
@@ -0,0 +1,99 @@
# Agent Bridge
Optional backend-side bridge for talking to Hermes Agent by instantiating
`run_agent.AIAgent` directly in a Python process.
This is intentionally separate from the current Web UI chat path.
## Python Service
```bash
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
Default endpoint:
```text
ipc:///tmp/hermes-agent-bridge.sock
```
On Windows, the default endpoint is TCP because Python may not support Unix
domain sockets there:
```text
tcp://127.0.0.1:18765
```
Override with:
```bash
HERMES_AGENT_BRIDGE_ENDPOINT=tcp://127.0.0.1:8765 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
Profile workers use the same platform defaults: TCP on Windows and IPC on
macOS/Linux. Override worker transport with:
```bash
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp HERMES_AGENT_BRIDGE_WORKER_PORT_BASE=18780 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
The service discovers Hermes Agent in this order:
1. `--agent-root`
2. `HERMES_AGENT_ROOT`
3. the installed `hermes` command path
4. current working directory and parent directories
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
6. the `hermes-agent` package installed in the selected Python environment
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
Default agent root:
```text
~/.hermes/hermes-agent
```
You can pass both paths explicitly:
```bash
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
--agent-root ~/.hermes/hermes-agent \
--hermes-home ~/.hermes
```
If no source checkout containing `run_agent.py` is found, the bridge falls back
to importing `run_agent` from the Python environment. This supports package
installs such as `pip install hermes-agent`. The Node manager prefers the source
checkout's virtualenv when a checkout exists, then the Python interpreter from
the installed `hermes` command, then the system Python.
The socket transport uses Python and Node standard libraries. No ZMQ dependency
is required.
## Backend Usage
```ts
import { AgentBridgeClient } from './services/hermes/agent-bridge'
const bridge = new AgentBridgeClient()
const run = await bridge.chat(sessionId, message)
for await (const chunk of bridge.streamOutput(run.run_id)) {
if (chunk.delta) {
// forward chunk.delta to Socket.IO/SSE/etc.
}
}
```
The external chat call only sends `session_id` and `message`. Provider, model,
keys, tools, reasoning, and session DB are resolved by hermes-agent from the
normal Hermes config and environment.
The bridge instantiates `AIAgent` with `platform="cli"` by default so behavior
matches CLI chat. Override it only if a caller intentionally needs a distinct
platform identity:
```bash
HERMES_AGENT_BRIDGE_PLATFORM=agent-bridge python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
```
@@ -0,0 +1,621 @@
import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net'
import { tmpdir } from 'os'
import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
import type { McpActionResponse } from '../mcp-types'
function resolveDefaultAgentBridgeEndpoint(): string {
if (process.env.VITEST) {
return process.platform === 'win32'
? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}`
: `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}`
}
return process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
}
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint()
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
function envPositiveInt(name: string): number | undefined {
const raw = process.env[name]
if (!raw) return undefined
const value = Number(raw)
return Number.isFinite(value) && value > 0 ? value : undefined
}
export type AgentBridgeStatus = 'running' | 'complete' | 'interrupted' | 'error'
export interface AgentBridgeOptions {
endpoint?: string
timeoutMs?: number
connectRetryMs?: number
}
export interface AgentBridgeRequestOptions {
timeoutMs?: number
serialize?: boolean
}
export interface AgentBridgeChatOptions {
force_compress?: boolean
storage_message?: AgentBridgeMessage
model?: string
provider?: string
source?: string
wait?: boolean
timeout?: number
}
export type AgentBridgeMessage =
| string
| Array<Record<string, unknown>>
export interface AgentBridgeResponse {
ok: true
[key: string]: unknown
}
export interface AgentBridgeChatStarted extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
}
export interface AgentBridgeOutput extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
delta: string
cursor: number
output: string
done: boolean
result?: unknown
error?: string | null
events: Array<Record<string, unknown>>
event_cursor: number
}
export interface AgentBridgeRunResult extends AgentBridgeResponse {
run_id: string
session_id: string
status: AgentBridgeStatus
output: string
deltas: string[]
events: unknown[]
result?: unknown
error?: string | null
}
export interface AgentBridgeContextEstimate extends AgentBridgeResponse {
session_id: string
token_count?: number | null
fixed_context_tokens?: number | null
system_prompt_tokens?: number | null
tool_tokens?: number | null
message_count: number
tool_count: number
tool_names?: string[]
system_prompt_chars: number
profile?: string
model?: string
provider?: string
}
export interface AgentBridgeCommandResult extends AgentBridgeResponse {
session_id: string
command: string
handled: boolean
type?: string
action?: string
message?: string
output?: string
notice?: string
loaded?: string[]
missing?: string[]
new_session_id?: string
history?: unknown[]
retry?: boolean
retry_input?: AgentBridgeMessage
title?: string
kickoff_prompt?: string
clear_goal_continuations?: boolean
max_turns?: number
}
export interface AgentBridgeGoalEvaluation extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
should_continue?: boolean
continuation_prompt?: string | null
verdict?: string
reason?: string
message?: string
}
export interface AgentBridgeGoalPause extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
reason?: string
message?: string
}
export class AgentBridgeError extends Error {
response?: unknown
}
export class AgentBridgeClient {
readonly endpoint: string
readonly timeoutMs: number
readonly connectRetryMs: number
private lock: Promise<unknown> = Promise.resolve()
constructor(options: AgentBridgeOptions = {}) {
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS
this.connectRetryMs = options.connectRetryMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS') ?? 5000
}
private summarizePayload(payload: Record<string, unknown>): Record<string, unknown> {
const action = String(payload.action || '')
const summary: Record<string, unknown> = { action }
for (const key of ['session_id', 'run_id', 'request_id', 'approval_id', 'profile', 'worker_key']) {
if (payload[key] != null) summary[key] = payload[key]
}
if (Array.isArray(payload.conversation_history)) summary.conversation_history_count = payload.conversation_history.length
if (Array.isArray(payload.messages)) summary.messages_count = payload.messages.length
if (typeof payload.message === 'string') summary.message_chars = payload.message.length
else if (Array.isArray(payload.message)) summary.message_parts = payload.message.length
if (typeof payload.command === 'string') summary.command = payload.command
if (typeof payload.text === 'string') summary.text_chars = payload.text.length
if (typeof payload.error === 'string') summary.error = payload.error
if (payload.force_compress === true) summary.force_compress = true
return summary
}
private summarizeResponse(response: Record<string, unknown>): Record<string, unknown> {
const summary: Record<string, unknown> = { ok: response.ok === true }
for (const key of ['session_id', 'run_id', 'request_id', 'status', 'cursor', 'event_cursor']) {
if (response[key] != null) summary[key] = response[key]
}
if (typeof response.delta === 'string') summary.delta_chars = response.delta.length
if (typeof response.output === 'string') summary.output_chars = response.output.length
if (Array.isArray(response.events)) summary.events_count = response.events.length
if (typeof response.error === 'string') summary.error = response.error
if (Array.isArray(response.history)) summary.history_count = response.history.length
return summary
}
private runtimeContext(payload: Record<string, unknown>): Record<string, unknown> {
const requestedProfile = typeof payload.profile === 'string' ? payload.profile.trim() : ''
let profile = requestedProfile || 'default'
try {
if (!requestedProfile) profile = getActiveProfileName()
} catch {}
const context: Record<string, unknown> = {
profile,
cwd: process.cwd(),
}
try {
const profileDir = getProfileDir(profile)
context.profile_dir = profileDir
context.config_path = join(profileDir, 'config.yaml')
} catch {}
return context
}
async connect(): Promise<this> {
return this
}
async close(): Promise<void> {
return undefined
}
private connectSocketOnce(): Promise<Socket> {
return new Promise((resolveConnect, rejectConnect) => {
const endpoint = this.endpoint
let socket: Socket
if (endpoint.startsWith('ipc://')) {
socket = createConnection(endpoint.slice('ipc://'.length))
} else if (endpoint.startsWith('tcp://')) {
const url = new URL(endpoint)
socket = createConnection({
host: url.hostname || '127.0.0.1',
port: Number(url.port),
})
} else {
rejectConnect(new Error(`unsupported agent bridge endpoint: ${endpoint}`))
return
}
const cleanup = () => {
socket.off('connect', onConnect)
socket.off('error', onError)
}
const onConnect = () => {
cleanup()
resolveConnect(socket)
}
const onError = (err: Error) => {
cleanup()
socket.destroy()
rejectConnect(err)
}
socket.once('connect', onConnect)
socket.once('error', onError)
})
}
private isRetryableConnectError(err: any): boolean {
const code = String(err?.code || '')
return ['ECONNREFUSED', 'ENOENT', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT'].includes(code)
}
private async connectSocket(): Promise<Socket> {
const deadline = Date.now() + Math.max(0, this.connectRetryMs)
for (;;) {
try {
return await this.connectSocketOnce()
} catch (err) {
if (!this.isRetryableConnectError(err) || Date.now() >= deadline) {
throw err
}
await delay(100)
}
}
}
private readResponse(socket: Socket, timeoutMs: number): Promise<string> {
return new Promise((resolveRead, rejectRead) => {
let buffer = ''
const timeout = timeoutMs > 0
? setTimeout(() => {
cleanup()
socket.destroy()
rejectRead(new Error(`Agent bridge request timed out after ${timeoutMs}ms`))
}, timeoutMs)
: null
const cleanup = () => {
if (timeout) clearTimeout(timeout)
socket.off('data', onData)
socket.off('error', onError)
socket.off('end', onEnd)
socket.off('close', onClose)
}
const finish = (line: string) => {
cleanup()
socket.end()
resolveRead(line)
}
const onData = (chunk: Buffer) => {
buffer += chunk.toString('utf8')
const idx = buffer.indexOf('\n')
if (idx >= 0) finish(buffer.slice(0, idx))
}
const onError = (err: Error) => {
cleanup()
socket.destroy()
rejectRead(err)
}
const onEnd = () => {
const line = buffer.trim()
if (line) finish(line)
}
const onClose = () => {
if (!buffer.trim()) {
cleanup()
rejectRead(new Error('Agent bridge socket closed without a response'))
}
}
socket.on('data', onData)
socket.once('error', onError)
socket.once('end', onEnd)
socket.once('close', onClose)
})
}
async request<T extends AgentBridgeResponse = AgentBridgeResponse>(
payload: Record<string, unknown>,
options: AgentBridgeRequestOptions = {},
): Promise<T> {
const run = async (): Promise<T> => {
const timeoutMs = options.timeoutMs || this.timeoutMs
const startedAt = Date.now()
const action = String(payload.action || '')
const shouldLogRequest = action !== 'get_output'
const runtimeContext = shouldLogRequest ? this.runtimeContext(payload) : undefined
if (shouldLogRequest) {
bridgeLogger.info({
endpoint: this.endpoint,
timeoutMs,
runtime: runtimeContext,
request: this.summarizePayload(payload),
}, '[agent-bridge-client] request')
}
try {
const socket = await this.connectSocket()
socket.write(`${JSON.stringify(payload)}\n`)
const raw = await this.readResponse(socket, timeoutMs)
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
if (!response.ok) {
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
error.response = response
bridgeLogger.warn({
durationMs: Date.now() - startedAt,
runtime: runtimeContext,
response: this.summarizeResponse(response as Record<string, unknown>),
}, '[agent-bridge-client] request rejected')
throw error
}
if (shouldLogRequest) {
bridgeLogger.info({
durationMs: Date.now() - startedAt,
runtime: runtimeContext,
response: this.summarizeResponse(response as Record<string, unknown>),
}, '[agent-bridge-client] response')
}
return response as T
} catch (err: any) {
if (!(err instanceof AgentBridgeError)) {
bridgeLogger.error({
durationMs: Date.now() - startedAt,
err: { message: err?.message, name: err?.name },
runtime: runtimeContext,
request: this.summarizePayload(payload),
}, '[agent-bridge-client] request failed')
}
throw err
}
}
if (!options.serialize) {
return run()
}
const next = this.lock.then(run, run)
this.lock = next.catch(() => undefined)
return next
}
ping(): Promise<AgentBridgeResponse> {
return this.request({ action: 'ping' })
}
chat(
sessionId: string,
message: AgentBridgeMessage,
conversationHistory?: unknown[],
instructions?: string,
profile?: string,
options: AgentBridgeChatOptions = {},
): Promise<AgentBridgeChatStarted> {
return this.request<AgentBridgeChatStarted>({
action: 'chat',
session_id: sessionId,
message,
...(options.storage_message !== undefined ? { storage_message: options.storage_message } : {}),
...(conversationHistory ? { conversation_history: conversationHistory } : {}),
...(instructions ? { instructions } : {}),
...(profile ? { profile } : {}),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
...(options.source ? { source: options.source } : {}),
...(options.wait ? { wait: true } : {}),
...(options.timeout ? { timeout: options.timeout } : {}),
...(options.force_compress ? { force_compress: true } : {}),
})
}
contextEstimate(
sessionId: string,
messages: unknown[],
instructions?: string,
profile?: string,
options: Pick<AgentBridgeChatOptions, 'model' | 'provider'> = {},
): Promise<AgentBridgeContextEstimate> {
return this.request<AgentBridgeContextEstimate>({
action: 'context_estimate',
session_id: sessionId,
messages,
...(instructions ? { instructions } : {}),
...(profile ? { profile } : {}),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
})
}
command(sessionId: string, command: string, profile?: string): Promise<AgentBridgeCommandResult> {
return this.request<AgentBridgeCommandResult>({
action: 'command',
session_id: sessionId,
command,
...(profile ? { profile } : {}),
})
}
goalEvaluate(sessionId: string, finalResponse: string, profile?: string): Promise<AgentBridgeGoalEvaluation> {
return this.request<AgentBridgeGoalEvaluation>({
action: 'goal_evaluate',
session_id: sessionId,
final_response: finalResponse,
...(profile ? { profile } : {}),
})
}
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
return this.request<AgentBridgeOutput>({
action: 'get_output',
run_id: runId,
cursor,
event_cursor: eventCursor,
}, options)
}
async *streamOutput(
runId: string,
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
): AsyncGenerator<AgentBridgeOutput> {
const intervalMs = options.intervalMs || 100
let cursor = 0
let eventCursor = 0
for (;;) {
const chunk = await this.getOutput(runId, cursor, eventCursor, options)
cursor = chunk.cursor
eventCursor = chunk.event_cursor
if (chunk.delta || chunk.done || (chunk.events && chunk.events.length > 0)) yield chunk
if (chunk.done) return
await delay(intervalMs)
}
}
async chatStream(
sessionId: string,
message: AgentBridgeMessage,
onDelta: (delta: string, chunk: AgentBridgeOutput) => void | Promise<void>,
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
): Promise<AgentBridgeOutput> {
const started = await this.chat(sessionId, message)
let last: AgentBridgeOutput | null = null
for await (const chunk of this.streamOutput(started.run_id, options)) {
last = chunk
if (chunk.delta) await onDelta(chunk.delta, chunk)
}
if (!last) throw new Error(`Agent bridge run ${started.run_id} produced no output state`)
return last
}
getResult(runId: string, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeRunResult> {
return this.request<AgentBridgeRunResult>({ action: 'get_result', run_id: runId }, options)
}
interrupt(sessionId: string, message?: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'interrupt',
session_id: sessionId,
message,
...(profile ? { profile } : {}),
})
}
goalPause(sessionId: string, reason: string, profile?: string): Promise<AgentBridgeGoalPause> {
return this.request<AgentBridgeGoalPause>({
action: 'goal_pause',
session_id: sessionId,
reason,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
session_id: sessionId,
text,
...(profile ? { profile } : {}),
})
}
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'approval_respond', approval_id: approvalId, choice })
}
clarifyRespond(clarifyId: string, response: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'clarify_respond', clarify_id: clarifyId, response })
}
compressionRespond(
requestId: string,
payload: { messages?: unknown[]; system_message?: string; error?: string },
): Promise<AgentBridgeResponse> {
return this.request({
action: 'compression_respond',
request_id: requestId,
...payload,
}, { timeoutMs: this.timeoutMs })
}
destroyAll(): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_all' }, { serialize: true })
}
destroyProfile(profile: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_profile', profile }, { serialize: true })
}
getHistory(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'get_history',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
status(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'status',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string, workerKey?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
session_id: sessionId,
...(profile ? { profile } : {}),
...(workerKey ? { worker_key: workerKey } : {}),
})
}
list(): Promise<AgentBridgeResponse> {
return this.request({ action: 'list' })
}
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' }, { serialize: true })
}
// ───── MCP Management ─────
mcpList(profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_list', ...(profile ? { profile } : {}) })
}
mcpAdd(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_add', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpUpdate(name: string, config: Record<string, unknown>, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_update', name, config, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpRemove(name: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_remove', name, ...(profile ? { profile } : {}) }, { serialize: true })
}
mcpTest(name: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_server_test', name, ...(profile ? { profile } : {}) }, { timeoutMs: 180_000 })
}
mcpTools(server?: string, profile?: string, raw?: boolean): Promise<McpActionResponse> {
return this.request({ action: 'mcp_tools_list', ...(server ? { server } : {}), ...(profile ? { profile } : {}), ...(raw ? { raw } : {}) })
}
mcpReload(server?: string, profile?: string): Promise<McpActionResponse> {
return this.request({ action: 'mcp_reload', ...(server ? { server } : {}), ...(profile ? { profile } : {}) }, { serialize: true })
}
}
export default AgentBridgeClient
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
export * from './client'
export * from './manager'
@@ -0,0 +1,606 @@
import { execFileSync, spawn, type ChildProcess } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { createConnection, createServer } from 'net'
import { dirname, isAbsolute, join, resolve } from 'path'
import { logger } from '../../logger'
import { detectHermesHome, getHermesBin } from '../hermes-path'
import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client'
const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000
const DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS = 1000
const MAX_AGENT_BRIDGE_RESTART_DELAY_MS = 30000
const OPENROUTER_WEB_UI_ATTRIBUTION_ENV = {
HERMES_OPENROUTER_APP_REFERER: 'https://hermes-studio.ai',
HERMES_OPENROUTER_APP_TITLE: 'Hermes Web UI',
HERMES_OPENROUTER_APP_CATEGORIES: 'cli-agent,personal-agent',
} as const
export interface AgentBridgeManagerOptions {
endpoint?: string
python?: string
agentRoot?: string
hermesHome?: string
startupTimeoutMs?: number
}
export interface BridgeCommand {
command: string
argsPrefix: string[]
agentRoot?: string
hermesHome: string
}
export interface AgentBridgeManagerRuntimeState {
endpoint: string
running: boolean
ready: boolean
pid?: number
starting: boolean
stopping: boolean
restartScheduled: boolean
restartAttempts: number
}
function envPositiveInt(name: string): number | undefined {
const raw = process.env[name]
if (!raw) return undefined
const value = Number(raw)
return Number.isFinite(value) && value > 0 ? value : undefined
}
export function buildAgentBridgeProcessEnv(endpoint: string, hermesHome: string | undefined, agentRoot: string | undefined): NodeJS.ProcessEnv {
return {
...process.env,
HERMES_AGENT_BRIDGE_ENDPOINT: endpoint,
HERMES_HOME: hermesHome,
HERMES_OPENROUTER_APP_REFERER: process.env.HERMES_OPENROUTER_APP_REFERER || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_REFERER,
HERMES_OPENROUTER_APP_TITLE: process.env.HERMES_OPENROUTER_APP_TITLE || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_TITLE,
HERMES_OPENROUTER_APP_CATEGORIES: process.env.HERMES_OPENROUTER_APP_CATEGORIES || OPENROUTER_WEB_UI_ATTRIBUTION_ENV.HERMES_OPENROUTER_APP_CATEGORIES,
...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}),
}
}
function pathCandidates(agentRoot?: string): string[] {
if (!agentRoot) return []
return process.platform === 'win32'
? [
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
]
: [
join(agentRoot, 'venv', 'bin', 'python3'),
join(agentRoot, 'venv', 'bin', 'python'),
join(agentRoot, '.venv', 'bin', 'python3'),
join(agentRoot, '.venv', 'bin', 'python'),
]
}
function uvCandidates(agentRoot?: string): string[] {
if (!agentRoot) {
return [
process.env.HERMES_AGENT_BRIDGE_UV,
process.env.UV,
].filter((value): value is string => !!value && value.trim().length > 0)
}
return [
process.env.HERMES_AGENT_BRIDGE_UV,
process.env.UV,
...(process.platform === 'win32'
? [
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '',
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '',
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '',
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '',
]
: [
agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '',
agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '',
]),
'uv',
].filter((value): value is string => !!value && value.trim().length > 0)
}
function resolveExecutable(command: string): string | undefined {
const trimmed = command.trim()
if (!trimmed) return undefined
if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
return existsSync(trimmed) ? resolve(trimmed) : undefined
}
try {
const lookup = process.platform === 'win32'
? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true })
: execFileSync('which', [trimmed], { encoding: 'utf-8' })
return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean)
} catch {
return undefined
}
}
function agentRootFromHermesBin(): string | undefined {
const hermesBin = resolveExecutable(getHermesBin())
if (!hermesBin) return undefined
const binDir = dirname(hermesBin)
const rootCandidates = [
resolve(binDir, '..'),
resolve(binDir, '..', '..'),
resolve(binDir, '..', 'hermes-agent'),
resolve(binDir, '..', 'lib', 'hermes-agent'),
resolve(binDir, '..', '..', 'hermes-agent'),
]
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
if (root) return root
try {
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
const match = first.match(/^#!\s*(.+)$/)
const python = match?.[1]?.trim().split(/\s+/)[0]
if (python) {
const pyDir = dirname(python)
const shebangRootCandidates = [
resolve(pyDir, '..', '..'),
resolve(pyDir, '..', '..', 'hermes-agent'),
resolve(pyDir, '..', '..', 'lib', 'hermes-agent'),
]
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
}
} catch {}
return undefined
}
function hermesBinPython(): string | undefined {
const hermesBin = resolveExecutable(getHermesBin())
if (!hermesBin) return undefined
try {
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
const match = first.match(/^#!\s*(.+)$/)
const python = match?.[1]?.trim().split(/\s+/)[0]
return python && existsSync(python) ? python : undefined
} catch {
return undefined
}
}
/** Python from `uv tool install hermes-agent` — avoids pydantic binary mismatches on Windows. */
function uvToolHermesPython(): string | undefined {
const home = process.env.HOME || process.env.USERPROFILE
const candidates = process.platform === 'win32'
? [
join(process.env.APPDATA || '', 'uv', 'tools', 'hermes-agent', 'Scripts', 'python.exe'),
home ? join(home, 'AppData', 'Roaming', 'uv', 'tools', 'hermes-agent', 'Scripts', 'python.exe') : '',
]
: [
home ? join(home, '.local', 'share', 'uv', 'tools', 'hermes-agent', 'bin', 'python3') : '',
home ? join(home, '.local', 'share', 'uv', 'tools', 'hermes-agent', 'bin', 'python') : '',
]
return firstExistingExecutable(candidates.filter((value): value is string => !!value && value.trim().length > 0))
}
function firstExistingExecutable(candidates: string[]): string | undefined {
for (const candidate of candidates) {
if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) {
const resolved = resolveExecutable(candidate)
if (resolved) return resolved
continue
}
try {
if (existsSync(candidate)) return candidate
} catch {}
}
return undefined
}
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
const candidates = [
explicit,
process.env.HERMES_AGENT_ROOT,
join(hermesHome, 'hermes-agent'),
agentRootFromHermesBin(),
process.cwd(),
join(process.cwd(), 'hermes-agent'),
'/usr/local/lib/hermes-agent',
'/usr/local/hermes-agent',
'/opt/hermes/hermes-agent',
'/opt/hermes-agent',
].filter((value): value is string => !!value && value.trim().length > 0)
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
}
export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {}): BridgeCommand {
const hermesHome = options.hermesHome || detectHermesHome()
const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome)
const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON
if (explicitPython) {
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
}
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
if (venvPython) {
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
}
const uvToolPython = uvToolHermesPython()
if (uvToolPython) {
return { command: uvToolPython, argsPrefix: [], agentRoot, hermesHome }
}
const shebangPython = hermesBinPython()
if (shebangPython && existsSync(shebangPython)) {
return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome }
}
const uv = firstExistingExecutable(uvCandidates(agentRoot))
if (uv) {
const prefix = ['run']
if (agentRoot) prefix.push('--project', agentRoot)
prefix.push('python')
return { command: uv, argsPrefix: prefix, agentRoot, hermesHome }
}
const fallback = firstExistingExecutable([
process.env.PYTHON || '',
...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']),
]) || (process.platform === 'win32' ? 'python' : 'python3')
return { command: fallback, argsPrefix: [], agentRoot, hermesHome }
}
function bridgeScriptPath(): string {
const candidates = [
// Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py
resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'),
// ts-node/dev source tree.
resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'),
resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
]
const found = candidates.find(candidate => existsSync(candidate))
if (!found) {
throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`)
}
return found
}
function isTcpEndpoint(endpoint: string): boolean {
return endpoint.startsWith('tcp://')
}
function isDesktopRuntime(): boolean {
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
}
async function canListenTcpEndpoint(endpoint: string): Promise<boolean> {
const url = new URL(endpoint)
const host = url.hostname || '127.0.0.1'
const port = Number(url.port)
if (!Number.isFinite(port) || port <= 0) return false
return await new Promise<boolean>((resolveAvailable) => {
const probe = createServer()
const done = (available: boolean) => {
probe.removeAllListeners()
resolveAvailable(available)
}
probe.once('error', () => done(false))
probe.listen(port, host, () => {
probe.close(() => done(true))
})
})
}
async function canConnectTcpEndpoint(endpoint: string): Promise<boolean> {
const url = new URL(endpoint)
const host = url.hostname || '127.0.0.1'
const port = Number(url.port)
if (!Number.isFinite(port) || port <= 0) return false
return await new Promise<boolean>((resolveConnected) => {
const socket = createConnection({ port, host })
const done = (connected: boolean) => {
socket.removeAllListeners()
socket.destroy()
resolveConnected(connected)
}
socket.setTimeout(250)
socket.once('connect', () => done(true))
socket.once('timeout', () => done(false))
socket.once('error', () => done(false))
})
}
function tcpEndpointPort(endpoint: string): number | undefined {
if (!isTcpEndpoint(endpoint)) return undefined
const url = new URL(endpoint)
const port = Number(url.port)
return Number.isFinite(port) && port > 0 ? port : undefined
}
function windowsListeningPidsOnPort(port: number): number[] {
try {
const output = execFileSync('netstat.exe', ['-ano', '-p', 'tcp'], { windowsHide: true }).toString('utf8')
const pids = new Set<number>()
for (const line of output.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 5) continue
const [proto, localAddress, , state, pidRaw] = parts
if (proto.toUpperCase() !== 'TCP' || state.toUpperCase() !== 'LISTENING') continue
if (!localAddress.endsWith(`:${port}`)) continue
const pid = Number(pidRaw)
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid)
}
return [...pids]
} catch {
return []
}
}
async function waitForTcpEndpoint(endpoint: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await canListenTcpEndpoint(endpoint)) return true
await new Promise(resolve => setTimeout(resolve, 100))
}
return canListenTcpEndpoint(endpoint)
}
async function killWindowsEndpointOccupants(endpoint: string): Promise<void> {
const port = tcpEndpointPort(endpoint)
if (!port) return
const pids = windowsListeningPidsOnPort(port)
if (!pids.length) return
for (const pid of pids) {
try {
logger.warn('[agent-bridge] killing stale process tree pid=%d on bridge port %d', pid, port)
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf-8', windowsHide: true })
} catch (err) {
logger.warn(err, '[agent-bridge] failed to kill stale bridge process pid=%d', pid)
}
}
await waitForTcpEndpoint(endpoint, 3000)
}
export class AgentBridgeManager {
endpoint: string
private readonly options: AgentBridgeManagerOptions
private readonly explicitEndpoint: boolean
private child: ChildProcess | null = null
private starting: Promise<void> | null = null
private ready = false
private stopping = false
private restartTimer: NodeJS.Timeout | null = null
private restartAttempts = 0
constructor(options: AgentBridgeManagerOptions = {}) {
this.options = options
this.explicitEndpoint = Boolean(options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT)
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
}
get running(): boolean {
return !!this.child && !this.child.killed && this.ready
}
getRuntimeState(): AgentBridgeManagerRuntimeState {
return {
endpoint: this.endpoint,
running: this.running,
ready: this.ready,
pid: this.child?.pid,
starting: !!this.starting,
stopping: this.stopping,
restartScheduled: !!this.restartTimer,
restartAttempts: this.restartAttempts,
}
}
async start(): Promise<void> {
if (this.running) return
if (this.starting) return this.starting
this.stopping = false
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.restartTimer = null
}
this.starting = this.startProcess()
try {
await this.starting
} finally {
this.starting = null
}
}
private async startProcess(): Promise<void> {
const script = bridgeScriptPath()
const command = resolveAgentBridgeCommand(this.options)
await this.prepareEndpoint()
const args = [...command.argsPrefix, script, '--endpoint', this.endpoint]
const agentRoot = command.agentRoot
const hermesHome = command.hermesHome
if (agentRoot) args.push('--agent-root', agentRoot)
if (hermesHome) args.push('--hermes-home', hermesHome)
const env = buildAgentBridgeProcessEnv(this.endpoint, hermesHome, agentRoot)
logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' '))
const child = spawn(command.command, args, {
env,
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
})
this.child = child
this.ready = false
child.once('exit', (code, signal) => {
const shouldRestart = this.ready && !this.stopping && this.child === child && this.autoRestartEnabled()
logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal)
this.ready = false
if (this.child === child) this.child = null
if (shouldRestart) this.scheduleRestart(code, signal)
})
child.stderr?.on('data', chunk => {
const text = String(chunk).trim()
if (text) logger.warn('[agent-bridge] %s', text)
})
await new Promise<void>((resolveReady, rejectReady) => {
let buffered = ''
const startupTimeoutMs = this.options.startupTimeoutMs
?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS')
?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS
const timeout = setTimeout(() => {
cleanup()
rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`))
}, startupTimeoutMs)
const cleanup = () => {
clearTimeout(timeout)
child.off('exit', onExitBeforeReady)
child.off('error', onError)
}
const markReady = () => {
if (readyResolved) return
this.ready = true
this.restartAttempts = 0
readyResolved = true
cleanup()
child.stdout?.off('data', onStdout)
resolveReady()
}
const onError = (err: Error) => {
cleanup()
child.stdout?.off('data', onStdout)
rejectReady(err)
}
const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup()
child.stdout?.off('data', onStdout)
rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`))
}
let readyResolved = false
const onStdout = (chunk: Buffer) => {
const text = chunk.toString('utf8')
buffered += text
for (;;) {
const newline = buffered.indexOf('\n')
if (newline < 0) break
const line = buffered.slice(0, newline).trim()
buffered = buffered.slice(newline + 1)
if (!line) continue
logger.info('[agent-bridge] %s', line)
if (!readyResolved) {
try {
const parsed = JSON.parse(line)
if (parsed?.event === 'ready') {
markReady()
return
}
} catch {}
}
}
}
child.once('error', onError)
child.once('exit', onExitBeforeReady)
child.stdout?.on('data', onStdout)
if (isDesktopRuntime() && isTcpEndpoint(this.endpoint)) {
const probe = async () => {
while (!readyResolved && !child.killed) {
if (await canConnectTcpEndpoint(this.endpoint)) {
markReady()
return
}
await new Promise(resolve => setTimeout(resolve, 100))
}
}
probe().catch(onError)
}
})
logger.info('[agent-bridge] ready at %s', this.endpoint)
}
private async prepareEndpoint(): Promise<void> {
if (!this.explicitEndpoint && process.platform === 'win32' && isTcpEndpoint(this.endpoint)) {
if (!(await canListenTcpEndpoint(this.endpoint))) {
await killWindowsEndpointOccupants(this.endpoint)
}
}
process.env.HERMES_AGENT_BRIDGE_ENDPOINT = this.endpoint
}
private autoRestartEnabled(): boolean {
const raw = String(process.env.HERMES_AGENT_BRIDGE_AUTO_RESTART || '').trim().toLowerCase()
return !['0', 'false', 'no', 'off'].includes(raw)
}
private scheduleRestart(code: number | null, signal: NodeJS.Signals | null): void {
if (this.restartTimer || this.stopping) return
this.restartAttempts += 1
const envDelay = envPositiveInt('HERMES_AGENT_BRIDGE_RESTART_DELAY_MS') ?? DEFAULT_AGENT_BRIDGE_RESTART_DELAY_MS
const delayMs = Math.min(
MAX_AGENT_BRIDGE_RESTART_DELAY_MS,
envDelay * Math.max(1, this.restartAttempts),
)
logger.warn(
'[agent-bridge] broker exited unexpectedly code=%s signal=%s; restarting in %dms (attempt %d)',
code,
signal,
delayMs,
this.restartAttempts,
)
this.restartTimer = setTimeout(() => {
this.restartTimer = null
if (this.stopping) return
this.start().catch((err) => {
logger.warn(err, '[agent-bridge] automatic restart failed')
if (!this.stopping) this.scheduleRestart(null, null)
})
}, delayMs)
}
async stop(): Promise<void> {
this.stopping = true
if (this.restartTimer) {
clearTimeout(this.restartTimer)
this.restartTimer = null
}
const child = this.child
if (!child) return
this.ready = false
this.child = null
await new Promise<void>((resolveStop) => {
const timeout = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL')
resolveStop()
}, 1500)
child.once('exit', () => {
clearTimeout(timeout)
resolveStop()
})
if (!child.killed) {
child.kill('SIGTERM')
}
})
}
}
let singleton: AgentBridgeManager | null = null
export function getAgentBridgeManager(): AgentBridgeManager {
if (!singleton) singleton = new AgentBridgeManager()
return singleton
}
export async function startAgentBridgeManager(): Promise<AgentBridgeManager> {
const manager = getAgentBridgeManager()
await manager.start()
return manager
}
@@ -0,0 +1,463 @@
import type {
StoredMessage,
CompressionConfig,
CompressedContext,
BuildContextInput,
MessageFetcher,
GatewayCaller,
SessionCleaner,
} from './types'
import { DEFAULT_COMPRESSION_CONFIG } from './types'
import { GatewaySummarizer } from './gateway-client'
import { buildAgentInstructions, buildSummarizationSystemPrompt } from './prompt'
import { logger } from '../../../services/logger'
export class ContextEngine {
private config: CompressionConfig
private messageFetcher: MessageFetcher
private gatewayCaller: GatewayCaller
/** Per-room compression lock to prevent concurrent snapshot overwrites */
private _compressLocks = new Map<string, Promise<void>>()
private _upstream = ''
private _apiKey: string | null = null
constructor(opts: {
config?: Partial<CompressionConfig>
messageFetcher: MessageFetcher
gatewayCaller?: GatewayCaller
sessionCleaner?: SessionCleaner
}) {
this.config = { ...DEFAULT_COMPRESSION_CONFIG, ...opts.config }
this.messageFetcher = opts.messageFetcher
this.gatewayCaller = opts.gatewayCaller || new GatewaySummarizer(this.config.summarizationTimeoutMs)
this.sessionCleaner = opts.sessionCleaner
}
private sessionCleaner?: SessionCleaner
setUpstream(upstream: string, apiKey: string | null): void {
this._upstream = upstream
this._apiKey = apiKey
}
/**
* Build context for an agent reply.
*
* Flow:
* 1. Read persisted snapshot (summary + lastMessageId) from SQLite
* 2. If snapshot exists:
* a. Collect new messages after lastMessageId
* b. Estimate tokens = summary + new messages
* c. Under threshold return as-is
* d. Over threshold incremental compress, update snapshot, return
* 3. If no snapshot:
* a. Estimate tokens for all messages
* b. Under threshold return all verbatim
* c. Over threshold full compress, save snapshot, return
*/
async buildContext(input: BuildContextInput): Promise<CompressedContext> {
// Serialize compression per room to prevent concurrent snapshot overwrites
const existing = this._compressLocks.get(input.roomId)
if (existing) {
await existing
}
let resolveLock!: () => void
const lock = new Promise<void>(r => { resolveLock = r })
this._compressLocks.set(input.roomId, lock)
try {
return await this._buildContextImpl(input)
} finally {
resolveLock()
this._compressLocks.delete(input.roomId)
}
}
private async _buildContextImpl(input: BuildContextInput): Promise<CompressedContext> {
const config = { ...this.config, ...input.compression }
const allMessages = this.messageFetcher.getMessages(input.roomId)
// Filter out messages newer than the current one
const messages = allMessages.filter(m => m.timestamp <= input.currentMessage.timestamp)
const total = messages.length
logger.debug(`[ContextEngine] buildContext START — room=${input.roomId}, agent=${input.agentName}, totalMessagesInDb=${allMessages.length}, afterFilter=${total}`)
const instructions = buildAgentInstructions({
agentName: input.agentName,
roomName: input.roomName,
agentDescription: input.agentDescription,
memberNames: input.memberNames,
members: input.members,
})
const meta: CompressedContext['meta'] = {
totalMessages: total,
verbatimCount: 0,
hadSnapshot: false,
compressed: false,
summaryTokenEstimate: 0,
}
const snapshot = this.messageFetcher.getContextSnapshot(input.roomId)
logger.debug(`[ContextEngine] snapshot=${snapshot ? `EXISTS (lastMsgId=${snapshot.lastMessageId}, summaryLen=${snapshot.summary.length})` : 'NONE'}`)
const estimateFullContextTokens = async (
history: Array<{ role: 'user' | 'assistant'; content: string }>,
messageTokenEstimate: number,
): Promise<number> => {
try {
const estimate = await input.contextTokenEstimator?.(history, instructions)
if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
return Math.floor(estimate)
}
} catch (err: any) {
logger.warn(`[ContextEngine] full context estimate failed room=${input.roomId}, agent=${input.agentName}: ${err.message}`)
}
return messageTokenEstimate
}
const logThresholdCheck = (path: string, messageTokens: number, fullTokens: number): void => {
meta.messageTokenEstimate = messageTokens
meta.contextTokenEstimate = fullTokens
logger.info({
roomId: input.roomId,
agentName: input.agentName,
profile: input.profile || 'default',
path,
messages: total,
messageOnlyTokens: messageTokens,
fullContextTokens: fullTokens,
triggerTokens: config.triggerTokens,
decision: fullTokens > config.triggerTokens ? 'compress' : 'skip',
}, '[ContextEngine] threshold check')
}
// ── Path A: Snapshot exists — incremental ────────────
if (snapshot) {
meta.hadSnapshot = true
// Find the position of lastMessageId in messages
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
// Collect messages after the snapshot position
const newMessages = snapshotIdx >= 0
? messages.slice(snapshotIdx + 1)
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
const summaryTokens = this.countTokens(snapshot.summary)
const newTokens = this.estimateTokensFromMessages(newMessages)
const messageOnlyTokens = summaryTokens + newTokens
meta.verbatimCount = newMessages.length
meta.summaryTokenEstimate = summaryTokens
const snapshotHistory = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
const totalTokens = await estimateFullContextTokens(snapshotHistory, messageOnlyTokens)
logThresholdCheck('snapshot', messageOnlyTokens, totalTokens)
logger.debug(`[ContextEngine] [Path A] snapshotIdx=${snapshotIdx}, newMessages=${newMessages.length}, summaryTokens=~${summaryTokens}, newTokens=~${newTokens}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`)
logger.debug(`[ContextEngine] [Path A] EXISTING SUMMARY (${snapshot.summary.length} chars): ${snapshot.summary.slice(0, 300)}`)
if (newMessages.length > 0) {
logger.debug(`[ContextEngine] [Path A] NEW MESSAGES (${newMessages.length}): ${newMessages.map(m => `[${m.senderName}]: ${m.content.slice(0, 80)}`).join(' | ')}`)
}
// Under threshold — return summary + new messages directly
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
this.logHistory('Path A (no compress)', snapshotHistory)
return { conversationHistory: snapshotHistory, instructions, meta }
}
// Over threshold — incremental compress
if (totalTokens > messageOnlyTokens && newMessages.length <= config.tailMessageCount) {
throw new Error(
`Context window is too small for group chat agent ${input.agentName}: fixed prompt/tool overhead plus ${newMessages.length} new messages uses ~${totalTokens} tokens, exceeding trigger ${config.triggerTokens}, and there is not enough history to compress.`,
)
}
logger.debug(`[ContextEngine] [Path A] OVER threshold — starting INCREMENTAL compression of ${newMessages.length} msgs...`)
logger.debug(`[ContextEngine] [Path A] CONTEXT BEFORE COMPRESSION: summary(${snapshot.summary.length} chars) + ${newMessages.length} new msgs`)
meta.compressed = true
input.onProgress?.({
status: 'compressing',
path: 'snapshot',
messageCount: newMessages.length,
tokenCount: totalTokens,
})
const t0 = Date.now()
const result = await this.summarize(
input.roomId,
newMessages,
input.upstream,
input.apiKey,
input.profile || 'default',
snapshot.summary,
)
const elapsed = Date.now() - t0
if (result.summary) {
const lastMsg = newMessages[newMessages.length - 1]
this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastMsg.id, lastMsg.timestamp)
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName)
meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history))
this.logHistory('Path A (after incremental compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
}
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
return { conversationHistory: history, instructions, meta }
}
// ── Path B: No snapshot — full context ───────────────
const messageOnlyTokens = this.estimateTokensFromMessages(messages)
meta.verbatimCount = total
const fullHistory = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
const totalTokens = await estimateFullContextTokens(fullHistory, messageOnlyTokens)
logThresholdCheck('full', messageOnlyTokens, totalTokens)
logger.debug(`[ContextEngine] [Path B] no snapshot, totalMessages=${total}, totalTokens=~${totalTokens}, threshold=${config.triggerTokens}`)
// Under threshold — pass all messages verbatim
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
this.logHistory('Path B (no compress)', fullHistory)
return { conversationHistory: fullHistory, instructions, meta }
}
// Over threshold — full compress
if (totalTokens > messageOnlyTokens && messages.length <= config.tailMessageCount) {
throw new Error(
`Context window is too small for group chat agent ${input.agentName}: fixed prompt/tool overhead plus ${messages.length} messages uses ~${totalTokens} tokens, exceeding trigger ${config.triggerTokens}, and there is not enough history to compress.`,
)
}
logger.debug(`[ContextEngine] [Path B] OVER threshold — starting FULL compression of ${total} msgs...`)
logger.debug(`[ContextEngine] [Path B] CONTEXT BEFORE COMPRESSION: ${total} msgs, ~${totalTokens} tokens`)
meta.compressed = true
input.onProgress?.({
status: 'compressing',
path: 'full',
messageCount: total,
tokenCount: totalTokens,
})
const t0 = Date.now()
const result = await this.summarize(
input.roomId,
messages,
input.upstream,
input.apiKey,
input.profile || 'default',
)
const elapsed = Date.now() - t0
if (result.summary) {
// Keep recent tail messages verbatim, compress the rest
const { tailMessageCount } = config
const toCompress = messages.length > tailMessageCount ? messages.slice(0, -tailMessageCount) : messages
const tail = messages.length > tailMessageCount ? messages.slice(-tailMessageCount) : []
const lastCompressedMsg = toCompress[toCompress.length - 1]
this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp)
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName)
meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history))
this.logHistory('Path B (after full compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
}
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.trimToBudget(history, 0, config.maxHistoryTokens)
meta.verbatimCount = history.length
return { conversationHistory: history, instructions, meta }
}
invalidateRoom(roomId: string): void {
this.messageFetcher.deleteContextSnapshot(roomId)
}
/**
* Force compress all messages in a room (full compression).
* Used when user manually triggers compression.
*/
async forceCompress(roomId: string, profile?: string): Promise<string> {
const allMessages = this.messageFetcher.getMessages(roomId)
if (allMessages.length === 0) return ''
const config = { ...this.config }
logger.debug(`[ContextEngine] forceCompress room=${roomId}, messages=${allMessages.length}`)
const t0 = Date.now()
const result = await this.summarize(roomId, allMessages, this._upstream, this._apiKey, profile || 'default')
const elapsed = Date.now() - t0
if (result.summary) {
const { tailMessageCount } = config
const toCompress = allMessages.length > tailMessageCount ? allMessages.slice(0, -tailMessageCount) : allMessages
const lastCompressedMsg = toCompress[toCompress.length - 1]
this.messageFetcher.saveContextSnapshot(roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp)
logger.debug(`[ContextEngine] forceCompress DONE in ${elapsed}ms`)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return result.summary
}
throw new Error('Compression failed')
}
// ─── Private ─────────────────────────────────────────────
/**
* Build history array: optional summary prefix + verbatim messages.
*/
private buildHistory(
summary: string,
messages: StoredMessage[],
agentSocketId: string,
agentName: string,
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
if (summary) {
history.push(
{ role: 'user', content: '[Previous conversation summary]\n' + summary },
{ role: 'assistant', content: 'I have reviewed the conversation history and understand the context.' },
)
}
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName)))
return history
}
/**
* Summarize messages. If previousSummary is provided, do incremental update.
*/
private async summarize(
roomId: string,
messages: StoredMessage[],
upstream: string,
apiKey: string | null,
profile: string,
previousSummary?: string,
): Promise<{ summary: string | null; sessionId: string | null }> {
if (messages.length === 0 && !previousSummary) return { summary: null, sessionId: null }
try {
const result = await this.gatewayCaller.summarize(
upstream,
apiKey,
buildSummarizationSystemPrompt(),
messages,
roomId,
profile,
previousSummary,
)
return { summary: result.summary, sessionId: result.sessionId }
} catch (err: any) {
logger.warn(`[ContextEngine] Summarization failed for room ${roomId}: ${err.message}`)
return { summary: null, sessionId: null }
} finally {
// Session cleanup handled here if sessionCleaner is provided
}
}
private mapToHistory(
msg: StoredMessage,
agentSocketId: string,
agentName: string,
): { role: 'user' | 'assistant'; content: string } {
const senderName = msg.senderName || 'unknown'
const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName
if (msg.role === 'tool') {
const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result'
return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` }
}
if (msg.role === 'assistant' && msg.tool_calls?.length) {
const toolsInfo = msg.tool_calls.map(tc => {
const name = tc.function?.name || 'unknown'
let args = tc.function?.arguments || '{}'
if (args.length > 4000) args = `${args.slice(0, 4000)}...`
return `[Calling tool: ${name} with arguments: ${args}]`
}).join('\n')
const content = msg.content?.trim()
return {
role: isOwnAgent ? 'assistant' : 'user',
content: content
? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`
: `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`,
}
}
return {
role: isOwnAgent ? 'assistant' : 'user',
content: this.formatAttributedContent(senderName, msg.content || ''),
}
}
private formatAttributedContent(senderName: string, content: string): string {
return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}`
}
private formatAttributionPrefix(senderName: string, _content?: string): string {
return `[${senderName}]: `
}
private stripMentions(content: string): string {
return String(content || '')
.replace(/@([^\s@]+)/g, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/^\s+/, '')
}
private trimToBudget(
history: Array<{ role: 'user' | 'assistant'; content: string }>,
summaryTokens: number,
maxTokens: number,
): void {
let totalTokens = summaryTokens + this.estimateTokens(history)
while (totalTokens > maxTokens && history.length > 0) {
history.pop()
totalTokens = summaryTokens + this.estimateTokens(history)
}
}
private estimateTokens(history: Array<{ role: string; content: string }>): number {
const text = history.map(m => m.content).join('')
return this.countTokens(text)
}
private estimateTokensFromMessages(messages: StoredMessage[]): number {
const text = messages.map(m => m.content).join('')
return this.countTokens(text)
}
/** Estimate tokens distinguishing CJK (~1.5 tok/char) from Latin (config.charsPerToken per char) */
private countTokens(text: string): number {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / this.config.charsPerToken)
}
/** Log assembled history for debugging */
private logHistory(label: string, history: Array<{ role: string; content: string }>): void {
const totalTokens = this.estimateTokens(history)
logger.debug(`[ContextEngine] ASSEMBLED HISTORY (${label}): ${history.length} entries, ~${totalTokens} tokens`)
for (const entry of history) {
const preview = entry.content.length > 150 ? entry.content.slice(0, 150) + '...' : entry.content
logger.debug(` [${entry.role}] ${preview}`)
}
}
}
@@ -0,0 +1,110 @@
import type { StoredMessage, GatewayCaller } from './types'
import {
buildSummarizationSystemPrompt,
buildFullSummaryPrompt,
buildIncrementalUpdatePrompt,
} from './prompt'
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge'
/**
* Calls the local bridge to produce LLM-generated summaries.
* The context engine owns history assembly; gateway storage/chaining is not used.
*/
export class GatewaySummarizer implements GatewayCaller {
private timeoutMs: number
constructor(timeoutMs = 30_000) {
this.timeoutMs = timeoutMs
}
async summarize(
_upstream: string,
_apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
roomId: string,
profile: string,
previousSummary?: string,
): Promise<{ summary: string; sessionId: string }> {
const history: Array<{ role: string; content: string }> = messages.map(m => ({
role: 'user',
content: summarizeMessageForPrompt(m),
}))
if (previousSummary) {
history.unshift(
{ role: 'user', content: `[Previous summary]\n${previousSummary}` },
{ role: 'assistant', content: 'Understood, I will update the summary.' },
)
}
const userPrompt = previousSummary
? buildIncrementalUpdatePrompt()
: buildFullSummaryPrompt()
const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 })
const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 160)
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: userPrompt,
instructions: systemPrompt || buildSummarizationSystemPrompt(),
conversation_history: history,
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(this.timeoutMs / 1000),
}, { timeoutMs: this.timeoutMs + 15_000 })
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
const payload = result.result as any
const output = String(payload?.final_response || result.output || '').trim()
if (!output) throw new Error('Empty summarization response')
const usage = payload?.usage || payload?.response?.usage
if (usage) {
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: payload?.model || payload?.response?.model || '',
profile,
})
}
logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`)
return { summary: output, sessionId }
} finally {
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
}
}
function summarizeMessageForPrompt(message: StoredMessage): string {
if (message.role === 'tool') {
const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result'
return `[${label}]\n${message.content || ''}`
}
if (message.role === 'assistant' && message.tool_calls?.length) {
const toolsInfo = message.tool_calls.map(tc => {
const name = tc.function?.name || 'tool'
const args = tc.function?.arguments || '{}'
return `${name}(${args})`
}).join(', ')
const content = message.content?.trim()
return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]`
}
return `[${message.senderName}]: ${message.content}`
}
@@ -0,0 +1,13 @@
export { ContextEngine } from './compressor'
export { GatewaySummarizer } from './gateway-client'
export { buildAgentInstructions, buildSummarizationSystemPrompt, buildFullSummaryPrompt, buildIncrementalUpdatePrompt } from './prompt'
export { DEFAULT_COMPRESSION_CONFIG } from './types'
export type {
StoredMessage,
CompressionConfig,
CompressedContext,
ContextSnapshot,
MessageFetcher,
GatewayCaller,
BuildContextInput,
} from './types'
@@ -0,0 +1,115 @@
// ─── Agent Identity Instructions ────────────────────────────
import type { MemberInfo } from './types'
import { getSystemPrompt } from '../../../lib/llm-prompt'
interface AgentInstructionsParams {
agentName: string
roomName: string
agentDescription: string
memberNames: string[]
members: MemberInfo[]
}
export function buildAgentInstructions(params: AgentInstructionsParams): string {
// Deduplicate members by name (primary key) to avoid duplicate roles
// If multiple entries have the same name, prefer the one with description
const uniqueMembersMap = new Map<string, MemberInfo>()
for (const m of params.members) {
const existing = uniqueMembersMap.get(m.name)
// Prefer entries with description
if (!existing || (m.description && !existing.description)) {
uniqueMembersMap.set(m.name, m)
}
}
const uniqueMembers = Array.from(uniqueMembersMap.values())
let memberSection: string
if (uniqueMembers.length > 0) {
memberSection = uniqueMembers
.map(m => m.description ? `- ${m.name}: ${m.description}` : `- ${m.name}`)
.join('\n')
} else if (params.memberNames.length > 0) {
// Deduplicate member names as well
const uniqueNames = Array.from(new Set(params.memberNames))
memberSection = uniqueNames.map(n => `- ${n}`).join('\n')
} else {
memberSection = '- 未知'
}
// Handle empty agent description
const roleDescription = params.agentDescription?.trim()
? params.agentDescription
: '专业的 AI 助手,随时准备协助解决问题。'
const basePrompt = `你是"${params.agentName}",群聊房间"${params.roomName}"中的 AI 助手。
${roleDescription}
${memberSection}
-
-
-
- AI
-
- "[发送者]: ..."仿
- 使使 @名字"[${params.agentName}]:"
-
-
- agent @名字 @某个成员
- agent agent @名字
- agent 使 @名字
- @
- @
- @ agent
- @名字
- @任何人`
return getSystemPrompt(basePrompt)
}
// ─── Summarization Prompts ─────────────────────────────────
export function buildSummarizationSystemPrompt(): string {
return `你是一个群聊对话的摘要助手。请创建一份结构化摘要,帮助 AI 助手快速理解完整的对话上下文并智能回复。
使
-
-
-
-
- 线
- "可行动信息"
-
- URL
-
- 500
- AI
- 使
- `
}
export function buildFullSummaryPrompt(): string {
return '请对上方对话创建一份简洁的摘要。只输出摘要内容。'
}
export function buildIncrementalUpdatePrompt(): string {
return '对话自上次摘要后有了新的内容。请更新摘要,整合新消息。保持相同格式,更新所有部分。只输出更新后的摘要。'
}
@@ -0,0 +1,49 @@
import type { SummaryCacheEntry } from './types'
const MAX_ENTRIES = 200
export class SummaryCache {
private cache = new Map<string, SummaryCacheEntry>()
private ttlMs: number
constructor(ttlMs = 120_000) {
this.ttlMs = ttlMs
}
get(roomId: string): SummaryCacheEntry | undefined {
const entry = this.cache.get(roomId)
if (!entry) return undefined
if (Date.now() - entry.createdAt >= this.ttlMs) {
this.cache.delete(roomId)
return undefined
}
return entry
}
set(roomId: string, entry: SummaryCacheEntry): void {
if (this.cache.size >= MAX_ENTRIES) {
let oldestKey = ''
let oldestTime = Infinity
for (const [k, v] of this.cache) {
if (v.createdAt < oldestTime) {
oldestTime = v.createdAt
oldestKey = k
}
}
if (oldestKey) this.cache.delete(oldestKey)
}
this.cache.set(roomId, entry)
}
invalidate(roomId: string): void {
this.cache.delete(roomId)
}
clear(): void {
this.cache.clear()
}
get size(): number {
return this.cache.size
}
}
@@ -0,0 +1,133 @@
// ─── Message Types ──────────────────────────────────────────
/** Raw message from SQLite messages table */
export interface StoredMessage {
id: string
roomId: string
senderId: string
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null
tool_name?: string | null
finish_reason?: string | null
}
// ─── Compression Config ────────────────────────────────────
export interface CompressionConfig {
/** Token threshold to trigger compression (estimate all messages) */
triggerTokens: number
/** Max tokens for the final compressed context sent to LLM */
maxHistoryTokens: number
/** Number of recent messages to keep verbatim after compression */
tailMessageCount: number
/** Characters per token for estimation */
charsPerToken: number
/** Timeout for summarization LLM call in ms */
summarizationTimeoutMs: number
}
export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
triggerTokens: 100_000,
maxHistoryTokens: 32_000,
tailMessageCount: 10,
charsPerToken: 6,
summarizationTimeoutMs: 30_000,
}
// ─── Compression Output ────────────────────────────────────
export interface CompressedContext {
conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }>
instructions: string
meta: {
totalMessages: number
verbatimCount: number
hadSnapshot: boolean
compressed: boolean
summaryTokenEstimate: number
contextTokenEstimate?: number
messageTokenEstimate?: number
}
}
// ─── Context Snapshot (persisted in SQLite) ────────────────
export interface ContextSnapshot {
roomId: string
summary: string
lastMessageId: string
lastMessageTimestamp: number
updatedAt: number
}
// ─── Summary Cache ──────────────────────────────────────────
export interface SummaryCacheEntry {
summary: string
lastMessageId: string
lastMessageTimestamp: number
createdAt: number
}
// ─── Dependency Injection ──────────────────────────────────
export interface MessageFetcher {
getMessages(roomId: string, limit?: number): StoredMessage[]
getContextSnapshot(roomId: string): ContextSnapshot | null
saveContextSnapshot(roomId: string, summary: string, lastMessageId: string, lastMessageTimestamp: number): void
deleteContextSnapshot(roomId: string): void
}
export interface GatewayCaller {
summarize(
upstream: string,
apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
roomId: string,
profile: string,
previousSummary?: string,
): Promise<{ summary: string; sessionId: string }>
}
export type SessionCleaner = (sessionId: string) => void
export type ContextProgress = (event: {
status: 'compressing'
path: 'snapshot' | 'full'
messageCount: number
tokenCount: number
}) => void
// ─── Build Context Input ───────────────────────────────────
export interface MemberInfo {
userId: string
name: string
description: string
}
export interface BuildContextInput {
roomId: string
agentId: string
agentName: string
agentDescription: string
agentSocketId: string
roomName: string
memberNames: string[]
members: MemberInfo[]
upstream: string
apiKey: string | null
currentMessage: StoredMessage
compression?: Partial<CompressionConfig>
profile?: string
contextTokenEstimator?: (
history: Array<{ role: 'user' | 'assistant'; content: string }>,
instructions: string,
) => Promise<number | null | undefined>
onProgress?: ContextProgress
}

Some files were not shown because too many files have changed in this diff Show More