fix: avoid full session export in session list (#38)
Read lightweight session summaries directly from state.db via node:sqlite instead of exporting full transcripts through hermes CLI. Falls back to CLI path if sqlite query fails. Includes title fallback from preview when no explicit title is set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
|
import { listSessionSummaries } from '../../services/hermes/sessions-db'
|
||||||
|
|
||||||
export const sessionRoutes = new Router()
|
export const sessionRoutes = new Router()
|
||||||
|
|
||||||
@@ -7,6 +8,15 @@ export const sessionRoutes = new Router()
|
|||||||
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
|
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
|
||||||
const source = (ctx.query.source as string) || undefined
|
const source = (ctx.query.source as string) || undefined
|
||||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 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)
|
const sessions = await hermesCli.listSessions(source, limit)
|
||||||
ctx.body = { sessions }
|
ctx.body = { sessions }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<string, unknown>): 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<HermesSessionRow[]> {
|
||||||
|
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<string, unknown>[]
|
||||||
|
: statement.all(limit) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
return rows.map(mapRow)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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' }] })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user