feat: add Koa2 BFF server, CLI management, sessions CLI integration, and logs page
- Add Koa2 BFF layer for API proxy, file upload, session management - Auto-check and enable api_server in ~/.hermes/config.yaml on startup - Integrate sessions with Hermes CLI (list, get, delete) - Add Logs page with level filtering, log file selection, and search - Add CLI commands: start/stop/restart/status for daemon management - Unify package.json for frontend and server dependencies - Default port changed to 8648 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { resolve } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '8648', 10),
|
||||
upstream: process.env.UPSTREAM || 'http://127.0.0.1:8642',
|
||||
uploadDir: process.env.UPLOAD_DIR || resolve(tmpdir(), 'hermes-uploads'),
|
||||
dataDir: resolve(__dirname, '..', 'data'),
|
||||
corsOrigins: process.env.CORS_ORIGINS || '*',
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { resolve } from 'path'
|
||||
import { mkdir } from 'fs/promises'
|
||||
import { config } from './config'
|
||||
import { proxyRoutes } from './routes/proxy'
|
||||
import { uploadRoutes } from './routes/upload'
|
||||
import { sessionRoutes } from './routes/sessions'
|
||||
import { webhookRoutes } from './routes/webhook'
|
||||
import { logRoutes } from './routes/logs'
|
||||
import * as hermesCli from './services/hermes-cli'
|
||||
const { restartGateway } = hermesCli
|
||||
|
||||
export async function bootstrap() {
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
await mkdir(config.dataDir, { recursive: true })
|
||||
await ensureApiServerConfig()
|
||||
|
||||
const app = new Koa()
|
||||
|
||||
app.use(cors({ origin: config.corsOrigins }))
|
||||
app.use(bodyParser())
|
||||
|
||||
app.use(webhookRoutes.routes())
|
||||
app.use(logRoutes.routes())
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(sessionRoutes.routes())
|
||||
|
||||
// Health endpoint with version
|
||||
app.use(async (ctx, next) => {
|
||||
if (ctx.path === '/health') {
|
||||
const raw = await hermesCli.getVersion()
|
||||
const version = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
||||
ctx.body = { status: 'ok', platform: 'hermes-agent', version }
|
||||
return
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// SPA fallback
|
||||
const distDir = resolve(__dirname, '..')
|
||||
app.use(serve(distDir))
|
||||
app.use(async (ctx) => {
|
||||
if (!ctx.path.startsWith('/api') && !ctx.path.startsWith('/v1') && ctx.path !== '/health' && ctx.path !== '/upload' && ctx.path !== '/webhook') {
|
||||
await send(ctx, 'index.html', { root: distDir })
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(` ➜ Hermes BFF Server: http://localhost:${config.port}`)
|
||||
console.log(` ➜ Upstream: ${config.upstream}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureApiServerConfig() {
|
||||
const { homedir } = await import('os')
|
||||
const { readFileSync, writeFileSync, existsSync } = await import('fs')
|
||||
const configPath = resolve(homedir(), '.hermes/config.yaml')
|
||||
|
||||
try {
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(' ✗ config.yaml not found, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
const content = readFileSync(configPath, 'utf-8')
|
||||
|
||||
// Case 1: api_server section exists, check if enabled is true
|
||||
if (/api_server:/.test(content)) {
|
||||
// Check specifically under api_server: look for a direct child `enabled: false`
|
||||
// Match api_server block and find enabled at the correct indent level
|
||||
const blockMatch = content.match(/api_server:\n((?:[ \t]+.*\n)*?)(?=\S|$)/)
|
||||
if (blockMatch) {
|
||||
const block = blockMatch[1]
|
||||
if (/^([ \t]*)enabled:\s*true/m.test(block)) {
|
||||
console.log(' ✓ api_server.enabled is true')
|
||||
return
|
||||
}
|
||||
if (/^([ \t]*)enabled:\s*false/m.test(block)) {
|
||||
// Backup before modifying
|
||||
const { copyFileSync } = await import('fs')
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const updated = content.replace(
|
||||
/(api_server:\n(?:[ \t]*.*\n)*?[ \t]*)enabled:\s*false/,
|
||||
'$1enabled: true'
|
||||
)
|
||||
writeFileSync(configPath, updated, 'utf-8')
|
||||
console.log(' ✓ api_server.enabled changed to true (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
return
|
||||
}
|
||||
}
|
||||
// api_server exists but no enabled key — don't touch, assume default
|
||||
console.log(' ✓ api_server section exists')
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: api_server section exists and enabled is true (or missing but default true)
|
||||
if (/api_server:/.test(content)) {
|
||||
console.log(' ✓ api_server section exists')
|
||||
return
|
||||
}
|
||||
|
||||
// Case 3: platforms section exists but no api_server — append api_server block
|
||||
if (/platforms:/.test(content)) {
|
||||
const { copyFileSync } = await import('fs')
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const append = `\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
|
||||
const updated = content.replace(/(platforms:)/, '$1' + append)
|
||||
writeFileSync(configPath, updated, 'utf-8')
|
||||
console.log(' ✓ api_server block appended to platforms (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
return
|
||||
}
|
||||
|
||||
// Case 4: No platforms section at all — append at end of file
|
||||
const { copyFileSync } = await import('fs')
|
||||
copyFileSync(configPath, configPath + '.bak')
|
||||
const append = `\nplatforms:\n api_server:\n enabled: true\n host: "127.0.0.1"\n port: 8642\n key: ""\n cors_origins: "*"\n`
|
||||
writeFileSync(configPath, content + append, 'utf-8')
|
||||
console.log(' ✓ platforms.api_server block appended (backup saved to config.yaml.bak)')
|
||||
await restartGateway()
|
||||
} catch (err: any) {
|
||||
console.error(' ✗ Failed to update config:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
@@ -0,0 +1,61 @@
|
||||
import Router from '@koa/router'
|
||||
import * as hermesCli from '../services/hermes-cli'
|
||||
|
||||
export const logRoutes = new Router()
|
||||
|
||||
// List available log files
|
||||
logRoutes.get('/api/logs', async (ctx) => {
|
||||
const files = await hermesCli.listLogFiles()
|
||||
ctx.body = { files }
|
||||
})
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
// Parse a single log line into structured entry
|
||||
function parseLine(line: string): LogEntry | null {
|
||||
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message
|
||||
const 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,
|
||||
}
|
||||
}
|
||||
// Unparseable line (e.g. traceback continuation)
|
||||
return null
|
||||
}
|
||||
|
||||
// Read log lines (parsed)
|
||||
logRoutes.get('/api/logs/:name', async (ctx) => {
|
||||
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
|
||||
|
||||
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) {
|
||||
// Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---"
|
||||
if (line.startsWith('---') || line.trim() === '') continue
|
||||
entries.push(parseLine(line))
|
||||
}
|
||||
|
||||
ctx.body = { entries }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Context } from 'koa'
|
||||
import { config } from '../config'
|
||||
|
||||
export async function proxy(ctx: Context) {
|
||||
const upstream = config.upstream.replace(/\/$/, '')
|
||||
const url = `${upstream}${ctx.path}${ctx.search || ''}`
|
||||
console.log(`[PROXY] ${ctx.method} ${ctx.path} -> ${url}`)
|
||||
|
||||
// Build headers — forward most, strip browser-specific ones
|
||||
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') {
|
||||
const v = Array.isArray(value) ? value[0] : value
|
||||
if (v) headers[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Add SSE-friendly headers
|
||||
if (ctx.path.match(/\/events$/)) {
|
||||
headers['x-accel-buffering'] = 'no'
|
||||
headers['cache-control'] = 'no-cache'
|
||||
}
|
||||
|
||||
try {
|
||||
// Build request body from raw body
|
||||
let body: string | undefined
|
||||
if (ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD') {
|
||||
body = (ctx as any).request.rawBody as string | undefined
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: ctx.req.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
// Set response headers
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((value, key) => {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower !== 'transfer-encoding' && lower !== 'connection') {
|
||||
resHeaders[key] = value
|
||||
}
|
||||
})
|
||||
if (ctx.path.match(/\/events$/)) {
|
||||
resHeaders['x-accel-buffering'] = 'no'
|
||||
resHeaders['cache-control'] = 'no-cache'
|
||||
}
|
||||
|
||||
ctx.status = res.status
|
||||
ctx.set(resHeaders)
|
||||
|
||||
// Stream response body
|
||||
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,8 @@
|
||||
import Router from '@koa/router'
|
||||
import { proxy } from './proxy-handler'
|
||||
|
||||
export const proxyRoutes = new Router()
|
||||
|
||||
// Proxy all /api/*, /v1/* to upstream Hermes API
|
||||
proxyRoutes.all('/api/(.*)', proxy)
|
||||
proxyRoutes.all('/v1/(.*)', proxy)
|
||||
@@ -0,0 +1,34 @@
|
||||
import Router from '@koa/router'
|
||||
import * as hermesCli from '../services/hermes-cli'
|
||||
|
||||
export const sessionRoutes = new Router()
|
||||
|
||||
// List sessions from Hermes
|
||||
sessionRoutes.get('/api/sessions', async (ctx) => {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const sessions = await hermesCli.listSessions(source, limit)
|
||||
ctx.body = { sessions }
|
||||
})
|
||||
|
||||
// Get single session with messages
|
||||
sessionRoutes.get('/api/sessions/:id', async (ctx) => {
|
||||
const session = await hermesCli.getSession(ctx.params.id)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = { session }
|
||||
})
|
||||
|
||||
// Delete session from Hermes
|
||||
sessionRoutes.delete('/api/sessions/:id', async (ctx) => {
|
||||
const ok = await hermesCli.deleteSession(ctx.params.id)
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete session' }
|
||||
return
|
||||
}
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import Router from '@koa/router'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { config } from '../config'
|
||||
|
||||
export const uploadRoutes = new Router()
|
||||
|
||||
uploadRoutes.post('/upload', async (ctx) => {
|
||||
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
|
||||
}
|
||||
|
||||
await mkdir(config.uploadDir, { recursive: true })
|
||||
|
||||
// Read raw body
|
||||
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)
|
||||
|
||||
const results: { name: string; path: string }[] = []
|
||||
|
||||
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() : ''
|
||||
const savedName = randomBytes(8).toString('hex') + ext
|
||||
const savedPath = `${config.uploadDir}/${savedName}`
|
||||
|
||||
await writeFile(savedPath, Buffer.from(data, 'binary'))
|
||||
results.push({ name: filename, path: savedPath })
|
||||
}
|
||||
|
||||
ctx.body = { files: results }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import Router from '@koa/router'
|
||||
import { emitWebhook } from '../services/hermes'
|
||||
|
||||
export const webhookRoutes = new Router()
|
||||
|
||||
/**
|
||||
* POST /webhook — receive callbacks from Hermes Agent
|
||||
*
|
||||
* Expected body:
|
||||
* {
|
||||
* "event": "run.completed" | "job.completed" | ...,
|
||||
* "run_id": "...",
|
||||
* "data": { ... }
|
||||
* }
|
||||
*
|
||||
* TODO: Add signature verification when Hermes supports webhook signing
|
||||
*/
|
||||
webhookRoutes.post('/webhook', async (ctx) => {
|
||||
const payload = ctx.request.body
|
||||
|
||||
if (!payload || !payload.event) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing event field' }
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[Webhook] Received event: ${payload.event}`)
|
||||
|
||||
// Emit to registered callbacks
|
||||
emitWebhook(payload)
|
||||
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface HermesSession {
|
||||
id: string
|
||||
source: string
|
||||
user_id: string | null
|
||||
model: 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
|
||||
billing_provider: string | null
|
||||
estimated_cost_usd: number
|
||||
messages?: any[]
|
||||
}
|
||||
|
||||
interface HermesSessionFull extends HermesSession {
|
||||
system_prompt?: string
|
||||
model_config?: string
|
||||
cache_read_tokens?: number
|
||||
cache_write_tokens?: number
|
||||
reasoning_tokens?: number
|
||||
actual_cost_usd?: number | null
|
||||
cost_status?: string
|
||||
cost_source?: string
|
||||
pricing_version?: string | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from Hermes CLI (without messages)
|
||||
*/
|
||||
export async function listSessions(source?: string, limit?: number): Promise<HermesSession[]> {
|
||||
const args = ['sessions', 'export', '-']
|
||||
if (source) args.push('--source', source)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const sessions: HermesSession[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const raw: HermesSessionFull = JSON.parse(line)
|
||||
sessions.push({
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title: raw.title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
})
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
|
||||
// Sort by started_at descending
|
||||
sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
if (limit && limit > 0) {
|
||||
return sessions.slice(0, limit)
|
||||
}
|
||||
return sessions
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] sessions export failed:', err.message)
|
||||
throw new Error(`Failed to list sessions: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session with messages from Hermes CLI
|
||||
*/
|
||||
export async function getSession(id: string): Promise<HermesSession | null> {
|
||||
const args = ['sessions', 'export', '-', '--session-id', id]
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
if (lines.length === 0) return null
|
||||
|
||||
const raw: HermesSessionFull = JSON.parse(lines[0])
|
||||
return {
|
||||
id: raw.id,
|
||||
source: raw.source,
|
||||
user_id: raw.user_id,
|
||||
model: raw.model,
|
||||
title: raw.title,
|
||||
started_at: raw.started_at,
|
||||
ended_at: raw.ended_at,
|
||||
end_reason: raw.end_reason,
|
||||
message_count: raw.message_count,
|
||||
tool_call_count: raw.tool_call_count,
|
||||
input_tokens: raw.input_tokens,
|
||||
output_tokens: raw.output_tokens,
|
||||
billing_provider: raw.billing_provider,
|
||||
estimated_cost_usd: raw.estimated_cost_usd,
|
||||
messages: raw.messages,
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === 1 || err.status === 1) return null
|
||||
console.error('[Hermes CLI] session export failed:', err.message)
|
||||
throw new Error(`Failed to get session: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from Hermes CLI
|
||||
*/
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
|
||||
timeout: 10000,
|
||||
})
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] session delete failed:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogFileInfo {
|
||||
name: string
|
||||
size: string
|
||||
modified: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Hermes version
|
||||
*/
|
||||
export async function getVersion(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000 })
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Hermes Agent
|
||||
*/
|
||||
export async function restartGateway(): Promise<string> {
|
||||
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
|
||||
timeout: 30000,
|
||||
})
|
||||
return stdout || stderr
|
||||
}
|
||||
|
||||
/**
|
||||
* List available log files
|
||||
*/
|
||||
export async function listLogFiles(): Promise<LogFileInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
|
||||
timeout: 10000,
|
||||
})
|
||||
const files: LogFileInfo[] = []
|
||||
const lines = stdout.trim().split('\n').filter(l => l.includes('.log'))
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s+(\S+)\s+([\d.]+\w+)\s+(.+)$/)
|
||||
if (match) {
|
||||
const rawName = match[1]
|
||||
const name = rawName.replace(/\.log$/, '')
|
||||
if (['agent', 'errors', 'gateway'].includes(name)) {
|
||||
files.push({ name, size: match[2], modified: match[3].trim() })
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] logs list failed:', err.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read log lines
|
||||
*/
|
||||
export async function readLogs(
|
||||
logName: string = 'agent',
|
||||
lines: number = 100,
|
||||
level?: string,
|
||||
session?: string,
|
||||
since?: string,
|
||||
): Promise<string> {
|
||||
const args = ['logs', logName, '-n', String(lines)]
|
||||
if (level) args.push('--level', level)
|
||||
if (session) args.push('--session', session)
|
||||
if (since) args.push('--since', since)
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('hermes', args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 15000,
|
||||
})
|
||||
return stdout
|
||||
} catch (err: any) {
|
||||
console.error('[Hermes CLI] logs read failed:', err.message)
|
||||
throw new Error(`Failed to read logs: ${err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { config } from '../config'
|
||||
|
||||
const UPSTREAM = config.upstream.replace(/\/$/, '')
|
||||
|
||||
/**
|
||||
* Send an instruction to Hermes Agent via /v1/runs
|
||||
*/
|
||||
export async function sendInstruction(params: {
|
||||
input: string | any[]
|
||||
instructions?: string
|
||||
conversationHistory?: any[]
|
||||
sessionId?: string
|
||||
authToken?: string
|
||||
}): Promise<{ run_id: string; status: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.authToken) {
|
||||
headers['Authorization'] = `Bearer ${params.authToken}`
|
||||
}
|
||||
|
||||
const body: any = { input: params.input }
|
||||
if (params.instructions) body.instructions = params.instructions
|
||||
if (params.conversationHistory) body.conversation_history = params.conversationHistory
|
||||
if (params.sessionId) body.session_id = params.sessionId
|
||||
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(`Hermes API error ${res.status}: ${text}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get run status (poll /v1/runs/:id if supported)
|
||||
*/
|
||||
export async function getRunStatus(runId: string): Promise<any> {
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get run status: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to SSE events for a run
|
||||
*/
|
||||
export async function* streamRunEvents(runId: string, authToken?: string): AsyncGenerator<any> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${UPSTREAM}/v1/runs/${runId}/events`, { headers })
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Failed to stream run events: ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') return
|
||||
try {
|
||||
const event = JSON.parse(data)
|
||||
yield event
|
||||
if (event.event === 'run.completed' || event.event === 'run.failed') return
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
export async function healthCheck(): Promise<{ status: string; version?: string }> {
|
||||
const res = await fetch(`${UPSTREAM}/health`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models
|
||||
*/
|
||||
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
|
||||
const res = await fetch(`${UPSTREAM}/v1/models`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Webhook callback registry
|
||||
type WebhookCallback = (payload: any) => void | Promise<void>
|
||||
const webhookCallbacks: WebhookCallback[] = []
|
||||
|
||||
export function onWebhook(callback: WebhookCallback) {
|
||||
webhookCallbacks.push(callback)
|
||||
}
|
||||
|
||||
export function emitWebhook(payload: any) {
|
||||
for (const cb of webhookCallbacks) {
|
||||
const result = cb(payload)
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch((err: Error) => console.error('Webhook callback error:', err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "../dist/server",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user