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] 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' }] }) + }) +})