From bcfbaa6a249e438fc29664cca92d327e40e13d84 Mon Sep 17 00:00:00 2001 From: Burak <12842695+debiansys@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:56:41 -0400 Subject: [PATCH 1/3] fix: avoid full session export in session list --- packages/server/src/routes/hermes/sessions.ts | 10 ++ .../server/src/services/hermes/sessions-db.ts | 130 ++++++++++++++++++ tests/server/sessions-db.test.ts | 125 +++++++++++++++++ tests/server/sessions-routes.test.ts | 52 +++++++ 4 files changed, 317 insertions(+) create mode 100644 packages/server/src/services/hermes/sessions-db.ts create mode 100644 tests/server/sessions-db.test.ts create mode 100644 tests/server/sessions-routes.test.ts diff --git a/packages/server/src/routes/hermes/sessions.ts b/packages/server/src/routes/hermes/sessions.ts index 58d630d..07970b7 100644 --- a/packages/server/src/routes/hermes/sessions.ts +++ b/packages/server/src/routes/hermes/sessions.ts @@ -1,5 +1,6 @@ import Router from '@koa/router' import * as hermesCli from '../../services/hermes/hermes-cli' +import { listSessionSummaries } from '../../services/hermes/sessions-db' export const sessionRoutes = new Router() @@ -7,6 +8,15 @@ export const sessionRoutes = new Router() sessionRoutes.get('/api/hermes/sessions', async (ctx) => { const source = (ctx.query.source as string) || undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined + + try { + const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000) + ctx.body = { sessions } + return + } catch (err) { + console.warn('[Hermes Session DB] summary query failed, falling back to CLI:', err) + } + const sessions = await hermesCli.listSessions(source, limit) ctx.body = { sessions } }) diff --git a/packages/server/src/services/hermes/sessions-db.ts b/packages/server/src/services/hermes/sessions-db.ts new file mode 100644 index 0000000..1b2adac --- /dev/null +++ b/packages/server/src/services/hermes/sessions-db.ts @@ -0,0 +1,130 @@ +import { DatabaseSync } from 'node:sqlite' +import { getActiveProfileDir } from './hermes-profile' + +export interface HermesSessionRow { + 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 + 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 +} + +function sessionDbPath(): string { + return `${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 mapRow(row: Record): HermesSessionRow { + const startedAt = normalizeNumber(row.started_at) + return { + id: String(row.id || ''), + source: String(row.source || ''), + user_id: normalizeNullableString(row.user_id), + model: String(row.model || ''), + title: normalizeNullableString(row.title), + started_at: startedAt, + ended_at: normalizeNullableNumber(row.ended_at), + 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: String(row.preview || ''), + last_active: normalizeNumber(row.last_active, startedAt), + } +} + +const BASE_SELECT = ` + SELECT + s.id, + s.source, + COALESCE(s.user_id, '') AS user_id, + COALESCE(s.model, '') AS model, + COALESCE(s.title, '') AS title, + 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, 63) + FROM messages 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, + COALESCE((SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), s.started_at) AS last_active + FROM sessions s + WHERE s.parent_session_id IS NULL + AND s.source != 'tool' +` + +export async function listSessionSummaries(source?: string, limit = 2000): Promise { + const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true }) + + try { + const sql = source + ? `${BASE_SELECT}\n AND s.source = ?\n ORDER BY s.started_at DESC\n LIMIT ?` + : `${BASE_SELECT}\n ORDER BY s.started_at DESC\n LIMIT ?` + + const statement = db.prepare(sql) + const rows = source + ? statement.all(source, limit) as Record[] + : statement.all(limit) as Record[] + + return rows.map(mapRow) + } finally { + db.close() + } +} diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts new file mode 100644 index 0000000..79da8d8 --- /dev/null +++ b/tests/server/sessions-db.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const allMock = vi.fn() +const prepareMock = vi.fn(() => ({ all: allMock })) +const closeMock = vi.fn() +const databaseSyncMock = vi.fn(() => ({ prepare: prepareMock, close: closeMock })) +const getActiveProfileDirMock = vi.fn(() => '/tmp/hermes-profile') + +vi.mock('node:sqlite', () => ({ + DatabaseSync: databaseSyncMock, +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + getActiveProfileDir: getActiveProfileDirMock, +})) + +describe('session DB summaries', () => { + beforeEach(() => { + vi.resetModules() + allMock.mockReset() + prepareMock.mockClear() + closeMock.mockClear() + databaseSyncMock.mockClear() + getActiveProfileDirMock.mockReset() + getActiveProfileDirMock.mockReturnValue('/tmp/hermes-profile') + }) + + it('queries sqlite for lightweight session summaries', async () => { + allMock.mockReturnValue([ + { + id: 's1', + source: 'cli', + user_id: '', + model: 'openai/gpt-5.4', + title: 'Named session', + started_at: 1710000000, + ended_at: null, + end_reason: '', + message_count: 3, + tool_call_count: 1, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'openrouter', + estimated_cost_usd: 0.01, + actual_cost_usd: null, + cost_status: 'estimated', + preview: 'hello world', + last_active: 1710000005, + }, + ]) + + const mod = await import('../../packages/server/src/services/hermes/sessions-db') + const rows = await mod.listSessionSummaries(undefined, 50) + + expect(databaseSyncMock).toHaveBeenCalledWith('/tmp/hermes-profile/state.db', { open: true, readOnly: true }) + expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining("AND s.source != 'tool'")) + expect(allMock).toHaveBeenCalledWith(50) + expect(closeMock).toHaveBeenCalled() + expect(rows).toEqual([ + { + id: 's1', + source: 'cli', + user_id: null, + model: 'openai/gpt-5.4', + title: 'Named session', + started_at: 1710000000, + ended_at: null, + end_reason: null, + message_count: 3, + tool_call_count: 1, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: 'openrouter', + estimated_cost_usd: 0.01, + actual_cost_usd: null, + cost_status: 'estimated', + preview: 'hello world', + last_active: 1710000005, + }, + ]) + }) + + it('adds source filter and falls back last_active to started_at', async () => { + allMock.mockReturnValue([ + { + id: 's2', + source: 'telegram', + user_id: '', + model: 'openai/gpt-5.4', + title: '', + started_at: 1710000100, + ended_at: null, + end_reason: '', + message_count: 1, + tool_call_count: 0, + input_tokens: 4, + output_tokens: 5, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: '', + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: '', + preview: 'preview text', + last_active: null, + }, + ]) + + const mod = await import('../../packages/server/src/services/hermes/sessions-db') + const rows = await mod.listSessionSummaries('telegram', 2) + + expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('AND s.source = ?')) + expect(allMock).toHaveBeenCalledWith('telegram', 2) + expect(rows[0].last_active).toBe(1710000100) + expect(rows[0].source).toBe('telegram') + expect(rows[0].title).toBeNull() + }) +}) diff --git a/tests/server/sessions-routes.test.ts b/tests/server/sessions-routes.test.ts new file mode 100644 index 0000000..8397e43 --- /dev/null +++ b/tests/server/sessions-routes.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listSessionSummariesMock = vi.fn() +const listSessionsMock = vi.fn() + +vi.mock('../../packages/server/src/services/hermes/sessions-db', () => ({ + listSessionSummaries: listSessionSummariesMock, +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ + listSessions: listSessionsMock, + getSession: vi.fn(), + deleteSession: vi.fn(), + renameSession: vi.fn(), +})) + +describe('session routes', () => { + beforeEach(() => { + vi.resetModules() + listSessionSummariesMock.mockReset() + listSessionsMock.mockReset() + }) + + it('serves summaries from sqlite-backed helper when available', async () => { + listSessionSummariesMock.mockResolvedValue([{ id: 's1' }]) + const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions') + const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions') + const handler = layer.stack[0] + const ctx: any = { query: { source: 'cli', limit: '5' }, body: null } + + await handler(ctx) + + expect(listSessionSummariesMock).toHaveBeenCalledWith('cli', 5) + expect(listSessionsMock).not.toHaveBeenCalled() + expect(ctx.body).toEqual({ sessions: [{ id: 's1' }] }) + }) + + it('falls back to CLI wrapper when sqlite summary query fails', async () => { + listSessionSummariesMock.mockRejectedValue(new Error('sqlite unavailable')) + listSessionsMock.mockResolvedValue([{ id: 'fallback' }]) + const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions') + const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions') + const handler = layer.stack[0] + const ctx: any = { query: { limit: '7' }, body: null } + + await handler(ctx) + + expect(listSessionSummariesMock).toHaveBeenCalledWith(undefined, 7) + expect(listSessionsMock).toHaveBeenCalledWith(undefined, 7) + expect(ctx.body).toEqual({ sessions: [{ id: 'fallback' }] }) + }) +}) From fd7071b75d29194ce9a169052885ed64505603cf Mon Sep 17 00:00:00 2001 From: ekko Date: Sat, 18 Apr 2026 08:53:45 +0800 Subject: [PATCH 2/3] fix: fallback title from preview when session has no explicit title SQLite path was returning null title for sessions without an explicit title, while the CLI path derives it from the first user message. Now uses the preview (first user message content) as title fallback, matching the original CLI behavior. Co-Authored-By: Claude Opus 4.6 --- packages/server/src/services/hermes/sessions-db.ts | 6 +++++- tests/server/sessions-db.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/hermes/sessions-db.ts b/packages/server/src/services/hermes/sessions-db.ts index 1b2adac..0367fcc 100644 --- a/packages/server/src/services/hermes/sessions-db.ts +++ b/packages/server/src/services/hermes/sessions-db.ts @@ -48,12 +48,16 @@ function normalizeNullableString(value: unknown): string | null { function mapRow(row: Record): HermesSessionRow { const startedAt = normalizeNumber(row.started_at) + const rawTitle = normalizeNullableString(row.title) + const preview = String(row.preview || '') + // Fallback: when no explicit title, use first user message as title (same as CLI path) + const title = rawTitle || (preview ? (preview.length > 40 ? preview.slice(0, 40) + '...' : preview) : null) return { id: String(row.id || ''), source: String(row.source || ''), user_id: normalizeNullableString(row.user_id), model: String(row.model || ''), - title: normalizeNullableString(row.title), + title, started_at: startedAt, ended_at: normalizeNullableNumber(row.ended_at), end_reason: normalizeNullableString(row.end_reason), diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts index 79da8d8..22efa3a 100644 --- a/tests/server/sessions-db.test.ts +++ b/tests/server/sessions-db.test.ts @@ -120,6 +120,6 @@ describe('session DB summaries', () => { expect(allMock).toHaveBeenCalledWith('telegram', 2) expect(rows[0].last_active).toBe(1710000100) expect(rows[0].source).toBe('telegram') - expect(rows[0].title).toBeNull() + expect(rows[0].title).toBe('preview text') }) }) From 35481e452db92fa497f4b4167c65ee5a91536ffa Mon Sep 17 00:00:00 2001 From: ekko Date: Sat, 18 Apr 2026 09:34:59 +0800 Subject: [PATCH 3/3] fix: use dynamic import for node:sqlite with Node version guard Replace static top-level import with runtime version check and dynamic import() so Node < 22.5 gracefully falls back to CLI path instead of crashing at module load time. Co-Authored-By: Claude Opus 4.6 --- packages/server/src/services/hermes/sessions-db.ts | 11 ++++++++++- tests/server/sessions-db.test.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/hermes/sessions-db.ts b/packages/server/src/services/hermes/sessions-db.ts index 0367fcc..1291d64 100644 --- a/packages/server/src/services/hermes/sessions-db.ts +++ b/packages/server/src/services/hermes/sessions-db.ts @@ -1,6 +1,10 @@ -import { DatabaseSync } from 'node:sqlite' import { getActiveProfileDir } from './hermes-profile' +const SQLITE_AVAILABLE = (() => { + const [major, minor] = process.versions.node.split('.').map(Number) + return major > 22 || (major === 22 && minor >= 5) +})() + export interface HermesSessionRow { id: string source: string @@ -115,6 +119,11 @@ const BASE_SELECT = ` ` export async function listSessionSummaries(source?: string, limit = 2000): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true }) try { diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts index 22efa3a..c0f0256 100644 --- a/tests/server/sessions-db.test.ts +++ b/tests/server/sessions-db.test.ts @@ -6,7 +6,7 @@ const closeMock = vi.fn() const databaseSyncMock = vi.fn(() => ({ prepare: prepareMock, close: closeMock })) const getActiveProfileDirMock = vi.fn(() => '/tmp/hermes-profile') -vi.mock('node:sqlite', () => ({ +vi.doMock('node:sqlite', () => ({ DatabaseSync: databaseSyncMock, }))