Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,54 @@ export async function bootstrap() {
logger.info('Auth enabled — token: %s', authToken)
}

// Cache policy + legacy service worker cleanup
const legacyServiceWorkerPaths = new Set([
'/service-worker.js',
'/sw.js',
'/ngsw-worker.js',
'/workbox-sw.js',
])

app.use(async (ctx, next) => {
if (legacyServiceWorkerPaths.has(ctx.path)) {
ctx.status = 200
ctx.type = 'application/javascript; charset=utf-8'
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
ctx.set('Pragma', 'no-cache')
ctx.set('Expires', '0')
ctx.body = `self.addEventListener('install',event=>{self.skipWaiting();});
self.addEventListener('activate',event=>{event.waitUntil((async()=>{const keys=await caches.keys();await Promise.all(keys.map(key=>caches.delete(key)));await self.registration.unregister();const clientsList=await self.clients.matchAll({type:'window',includeUncontrolled:true});for(const client of clientsList)client.navigate(client.url);})());});
`
return
}

await next()

if (ctx.path === '/' ||
ctx.path === '/index.html' ||
(!ctx.path.startsWith('/api') &&
!ctx.path.startsWith('/assets/') &&
!ctx.path.startsWith('/upload') &&
!ctx.path.startsWith('/socket.io') &&
!ctx.path.includes('.'))) {
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
ctx.set('Pragma', 'no-cache')
ctx.set('Expires', '0')
} else if (ctx.path.startsWith('/assets/')) {
ctx.set('Cache-Control', 'public, max-age=31536000, immutable')
}
})
console.log('[bootstrap] cache-control middleware 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') {
ctx.path !== '/webhook' &&
!legacyServiceWorkerPaths.has(ctx.path)) {
await send(ctx, 'index.html', { root: distDir })
}
})
Expand Down
233 changes: 124 additions & 109 deletions packages/server/src/services/hermes/session-sync.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,25 @@
/**
* Sync Hermes sessions from all profiles on startup.
* Reads api_server sessions from Hermes state.db and imports into local DB.
* Only runs when local DB is empty (first startup).
* Reads webui/api_server sessions from Hermes state.db and imports missing sessions into local DB.
* Runs incrementally and skips sessions that were already mirrored.
*
* Uses sessions-db.ts query logic to properly aggregate session chains.
*/
import { readdirSync, existsSync } from 'fs'
import { resolve, join } from 'path'
import { homedir } from 'os'
import { randomBytes } from 'crypto'
import { getProfileDir } from './hermes-profile'
import { createSession, addMessage, updateSession } from '../../db/hermes/session-store'
import { createSession, addMessage, updateSession, getSession } from '../../db/hermes/session-store'
import { getDb } from '../../db/index'
import { logger } from '../logger'
import { listSessionSummaries as listHermesSessionSummaries } from '../../db/hermes/sessions-db'
import {
getSessionDetailFromDbWithProfile,
listSessionSummaries as listHermesSessionSummaries,
} from '../../db/hermes/sessions-db'

const HERMES_BASE = resolve(homedir(), '.hermes')
const PROFILES_DIR = join(HERMES_BASE, 'profiles')

/**
* Generate a UUID v4 without external dependencies
*/
function generateUuid(): string {
const bytes = randomBytes(16)
bytes[6] = (bytes[6]! & 0x0f) | 0x40 // Version 4
bytes[8] = (bytes[8]! & 0x3f) | 0x80 // Variant 10
return [
bytes.subarray(0, 4).toString('hex'),
bytes.subarray(4, 6).toString('hex'),
bytes.subarray(6, 8).toString('hex'),
bytes.subarray(8, 10).toString('hex'),
bytes.subarray(10, 16).toString('hex'),
].join('-')
}
const SYNC_SOURCES = ['webui', 'api_server'] as const

/**
* Get all available profile names including 'default'
Expand All @@ -50,130 +37,158 @@ function getAllProfiles(): string[] {
return profiles
}

function hasMatchingLegacyImport(profile: string, hermesSession: { source: string; started_at: number; title: string | null }): boolean {
const db = getDb()
if (!db) return false

const row = db.prepare(
`SELECT id FROM sessions
WHERE profile = ?
AND source = ?
AND started_at = ?
AND COALESCE(title, '') = COALESCE(?, '')
LIMIT 1`,
).get(profile, hermesSession.source, hermesSession.started_at, hermesSession.title) as { id: string } | undefined

return !!row
}

function isAlreadySynced(profile: string, localSessionId: string, hermesSession: { source: string; started_at: number; title: string | null }): boolean {
return !!getSession(localSessionId) || hasMatchingLegacyImport(profile, hermesSession)
}

/**
* Sync api_server sessions from a single profile.
* Sync configured Hermes session sources from a single profile.
* Uses sessions-db.ts query logic to properly aggregate session chains.
*/
async function syncProfileSessions(profile: string): Promise<{
synced: number
skipped: number
errors: string[]
}> {
const result = { synced: 0, errors: [] as string[] }

try {
// Use listSessionSummaries to get aggregated session chains
// This returns only root sessions with aggregated stats from the entire chain
const summaries = await listHermesSessionSummaries('api_server', 10000, profile)

logger.info(`[session-sync] profile '${profile}': found ${summaries.length} aggregated session chains`)

for (const hermesSession of summaries) {
// Skip ephemeral sessions (created internally by chat-run-socket)
if (hermesSession.id.startsWith('eph_')) continue
try {
// Generate new session ID for local DB
const newSessionId = generateUuid()

// Create session in local DB
createSession({
id: newSessionId,
profile,
model: hermesSession.model,
title: hermesSession.title || undefined,
})

// Get full detail including all messages from the session chain
const { getSessionDetailFromDbWithProfile } = await import('../../db/hermes/sessions-db')
const detail = await getSessionDetailFromDbWithProfile(hermesSession.id, profile)

if (!detail || !detail.messages) {
result.errors.push(`session ${hermesSession.id}: failed to load messages`)
logger.warn(`[session-sync] failed to load messages for session ${hermesSession.id}`)
continue
}
const result = { synced: 0, skipped: 0, errors: [] as string[] }

for (const source of SYNC_SOURCES) {
try {
// Use listSessionSummaries to get aggregated session chains.
// This returns only root sessions with aggregated stats from the entire chain.
const summaries = await listHermesSessionSummaries(source, 10000, profile)

logger.info(`[session-sync] profile '${profile}', source '${source}': found ${summaries.length} aggregated session chains`)

for (const hermesSession of summaries) {
// Skip ephemeral sessions (created internally by chat-run-socket)
if (hermesSession.id.startsWith('eph_')) continue

try {
// Use Hermes' stable session id in the local mirror so startup sync is idempotent.
const localSessionId = hermesSession.id

if (isAlreadySynced(profile, localSessionId, hermesSession)) {
result.skipped++
continue
}

// Get full detail including all messages from the session chain before mutating the mirror.
const detail = await getSessionDetailFromDbWithProfile(hermesSession.id, profile)

if (!detail || !detail.messages) {
result.errors.push(`session ${hermesSession.id}: failed to load messages`)
logger.warn(`[session-sync] failed to load messages for session ${hermesSession.id}`)
continue
}

// Create session in local DB
createSession({
id: localSessionId,
profile,
model: hermesSession.model,
title: hermesSession.title || undefined,
})

// Insert all messages from the entire chain
for (const msg of detail.messages) {
addMessage({
session_id: newSessionId,
role: msg.role,
content: msg.content,
tool_call_id: msg.tool_call_id,
tool_calls: msg.tool_calls,
tool_name: msg.tool_name,
timestamp: msg.timestamp,
token_count: msg.token_count,
finish_reason: msg.finish_reason,
reasoning: msg.reasoning,
reasoning_details: msg.reasoning_details,
reasoning_content: msg.reasoning_content,
codex_reasoning_items: msg.codex_reasoning_items,
// Insert all messages from the entire chain
for (const msg of detail.messages) {
addMessage({
session_id: localSessionId,
role: msg.role,
content: msg.content,
tool_call_id: msg.tool_call_id,
tool_calls: msg.tool_calls,
tool_name: msg.tool_name,
timestamp: msg.timestamp,
token_count: msg.token_count,
finish_reason: msg.finish_reason,
reasoning: msg.reasoning,
reasoning_details: msg.reasoning_details,
reasoning_content: msg.reasoning_content,
codex_reasoning_items: msg.codex_reasoning_items,
})
}

// Update session with aggregated stats from Hermes
updateSession(localSessionId, {
source: hermesSession.source,
user_id: hermesSession.user_id,
started_at: hermesSession.started_at,
ended_at: hermesSession.ended_at,
end_reason: hermesSession.end_reason,
message_count: detail.messages.length,
tool_call_count: hermesSession.tool_call_count,
input_tokens: hermesSession.input_tokens,
output_tokens: hermesSession.output_tokens,
cache_read_tokens: hermesSession.cache_read_tokens,
cache_write_tokens: hermesSession.cache_write_tokens,
reasoning_tokens: hermesSession.reasoning_tokens,
billing_provider: hermesSession.billing_provider,
estimated_cost_usd: hermesSession.estimated_cost_usd,
actual_cost_usd: hermesSession.actual_cost_usd,
cost_status: hermesSession.cost_status,
last_active: hermesSession.last_active,
preview: hermesSession.preview,
})
}

// Update session with aggregated stats from Hermes
updateSession(newSessionId, {
started_at: hermesSession.started_at,
ended_at: hermesSession.ended_at,
end_reason: hermesSession.end_reason,
input_tokens: hermesSession.input_tokens,
output_tokens: hermesSession.output_tokens,
cache_read_tokens: hermesSession.cache_read_tokens,
cache_write_tokens: hermesSession.cache_write_tokens,
reasoning_tokens: hermesSession.reasoning_tokens,
estimated_cost_usd: hermesSession.estimated_cost_usd,
last_active: hermesSession.last_active,
preview: hermesSession.preview,
})

result.synced++
logger.info(`[session-sync] synced Hermes session ${hermesSession.id} -> ${newSessionId} (${detail.messages.length} messages, thread_session_count=${detail.thread_session_count})`)
} catch (err: any) {
result.errors.push(`session ${hermesSession.id}: ${err.message}`)
logger.warn(err, `[session-sync] failed to sync session ${hermesSession.id}`)
result.synced++
logger.info(`[session-sync] synced Hermes session ${hermesSession.id} (${source}) -> ${localSessionId} (${detail.messages.length} messages, thread_session_count=${detail.thread_session_count})`)
} catch (err: any) {
result.errors.push(`session ${hermesSession.id}: ${err.message}`)
logger.warn(err, `[session-sync] failed to sync session ${hermesSession.id}`)
}
}
} catch (err: any) {
if (!err.message.includes('state.db not found')) {
result.errors.push(`${source}: ${err.message}`)
logger.warn(err, `[session-sync] failed to open state.db for profile '${profile}', source '${source}'`)
}
}
} catch (err: any) {
if (!err.message.includes('state.db not found')) {
result.errors.push(err.message)
logger.warn(err, `[session-sync] failed to open state.db for profile '${profile}'`)
}
}

return result
}

/**
* Main entry point: sync all profiles on startup
* Only runs if local DB is empty (first startup or after DB reset)
* Main entry point: incrementally sync missing Hermes web UI/API sessions.
*/
export async function syncAllHermesSessionsOnStartup(): Promise<void> {
// Check if local DB has any sessions - only sync if completely empty
const db = getDb()
if (!db) {
logger.info('[session-sync] SQLite not available, skipping Hermes sync')
return
}

const countResult = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number } | undefined
const hasExistingSessions = countResult && countResult.count > 0

if (hasExistingSessions) {
logger.info('[session-sync] local DB has %d sessions, skipping Hermes sync', countResult!.count)
return
}

logger.info('[session-sync] local DB is empty, starting Hermes session sync...')
logger.info('[session-sync] local DB has %d sessions, starting incremental Hermes session sync...', countResult?.count ?? 0)

const profiles = getAllProfiles()
logger.info(`[session-sync] found ${profiles.length} profiles: ${profiles.join(', ')}`)

let totalSynced = 0
let totalSkipped = 0
let totalErrors = 0

for (const profile of profiles) {
const result = await syncProfileSessions(profile)
totalSynced += result.synced
totalSkipped += result.skipped
totalErrors += result.errors.length

if (result.errors.length > 0) {
Expand All @@ -187,5 +202,5 @@ export async function syncAllHermesSessionsOnStartup(): Promise<void> {
}
}

logger.info(`[session-sync] sync complete: synced=${totalSynced}, errors=${totalErrors}`)
logger.info(`[session-sync] sync complete: synced=${totalSynced}, skipped=${totalSkipped}, errors=${totalErrors}`)
}
Loading