feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 || '*',
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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/.env,apps.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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 : ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 图片语法:
|
||||
|
||||
\`\`\`
|
||||

|
||||

|
||||

|
||||
\`\`\`
|
||||
|
||||
## 视频格式
|
||||
|
||||
使用 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)
|
||||

|
||||
\`\`\`
|
||||
|
||||
## 文件链接格式
|
||||
|
||||
使用 Markdown 链接语法:
|
||||
|
||||
\`\`\`
|
||||
[下载报告](/tmp/monthly-report.pdf)
|
||||
[下载报告](<C:/Users/Administrator/Desktop/monthly-report.pdf>)
|
||||
\`\`\`
|
||||
|
||||
## 发送文件给用户
|
||||
|
||||
当用户要求"发给我"、"发送给我"、"传给我"等请求文件时,使用上述格式返回文件路径:
|
||||
|
||||
\`\`\`
|
||||

|
||||

|
||||
[视频名](/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');
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user