diff --git a/packages/client/public/favicon.svg b/packages/client/public/favicon.svg deleted file mode 100644 index 6893eb13..00000000 --- a/packages/client/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts index bb1556ec..41a16de7 100644 --- a/packages/server/src/controllers/hermes/kanban.ts +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -3,7 +3,12 @@ import { readFile } from 'fs/promises' import { resolve, normalize } from 'path' import { homedir } from 'os' import * as kanbanCli from '../../services/hermes/hermes-kanban' -import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db' +import { + searchSessionSummariesWithProfile, + getSessionDetailFromDbWithProfile, + getExactSessionDetailFromDbWithProfile, + findLatestExactSessionIdWithProfile, +} from '../../db/hermes/sessions-db' function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null { return [...detail.runs].reverse().find(run => run.profile)?.profile || null @@ -34,13 +39,12 @@ export async function get(ctx: Context) { const profile = getLatestRunProfile(detail) if (profile) { try { - const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5) - if (results.length > 0) { - const sessionId = results[0].id - const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile) + const exactSessionId = await findLatestExactSessionIdWithProfile(detail.task.id, profile) + if (exactSessionId) { + const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile) if (sessionDetail) { ;(detail as any).session = { - id: sessionId, + id: exactSessionId, title: sessionDetail.title, source: sessionDetail.source, model: sessionDetail.model, @@ -49,6 +53,23 @@ export async function get(ctx: Context) { messages: sessionDetail.messages, } } + } else { + const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5) + if (results.length > 0) { + const sessionId = results[0].id + const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile) + if (sessionDetail) { + ;(detail as any).session = { + id: sessionId, + title: sessionDetail.title, + source: sessionDetail.source, + model: sessionDetail.model, + started_at: sessionDetail.started_at, + ended_at: sessionDetail.ended_at, + messages: sessionDetail.messages, + } + } + } } } catch { // Session lookup is best-effort, don't fail the whole request @@ -215,6 +236,42 @@ export async function searchSessions(ctx: Context) { return } try { + if (!q) { + const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile) + if (exactSessionId) { + const sessionDetail = await getExactSessionDetailFromDbWithProfile(exactSessionId, profile) + if (sessionDetail) { + ctx.body = { + results: [{ + id: exactSessionId, + source: sessionDetail.source, + title: sessionDetail.title, + preview: sessionDetail.preview, + model: sessionDetail.model, + started_at: sessionDetail.started_at, + ended_at: sessionDetail.ended_at, + last_active: sessionDetail.last_active, + message_count: sessionDetail.message_count, + tool_call_count: sessionDetail.tool_call_count, + input_tokens: sessionDetail.input_tokens, + output_tokens: sessionDetail.output_tokens, + cache_read_tokens: sessionDetail.cache_read_tokens, + cache_write_tokens: sessionDetail.cache_write_tokens, + reasoning_tokens: sessionDetail.reasoning_tokens, + billing_provider: sessionDetail.billing_provider, + estimated_cost_usd: sessionDetail.estimated_cost_usd, + actual_cost_usd: sessionDetail.actual_cost_usd, + cost_status: sessionDetail.cost_status, + matched_message_id: null, + snippet: sessionDetail.preview, + rank: 0, + }], + } + return + } + } + } + const searchQuery = q || task_id const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10) ctx.body = { results } diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index 942f7550..671c35d4 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -696,6 +696,139 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi } } +export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise { + const { DatabaseSync } = await import('node:sqlite') + const dbPath = `${getProfileDir(profile)}/state.db` + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + try { + const idx = loadAllSessions(db) + const requested = idx.byId.get(sessionId) || null + if (!requested) return null + + const messageRows = db.prepare(` + SELECT + id, + session_id, + role, + content, + tool_call_id, + tool_calls, + tool_name, + timestamp, + token_count, + finish_reason, + reasoning, + reasoning_details, + codex_reasoning_items, + reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id + `).all(sessionId) as Record[] + const messages = messageRows.map(mapMessageRow) + return aggregateSessionDetail([requested], messages, sessionId) + } finally { + db.close() + } +} + +export async function findLatestExactSessionIdWithProfile( + query: string, + profile: string, + source?: string, +): Promise { + if (!SQLITE_AVAILABLE) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } + + const trimmed = query.trim() + if (!trimmed) return null + + const { DatabaseSync } = await import('node:sqlite') + const dbPath = `${getProfileDir(profile)}/state.db` + const db = new DatabaseSync(dbPath, { open: true, readOnly: true }) + const loweredQuery = trimmed.toLowerCase() + const likePattern = buildLikePattern(loweredQuery) + const kanbanPrompt = `work kanban task ${trimmed}`.toLowerCase() + const taskJsonNeedle = `"task_id": "${trimmed}"`.toLowerCase() + + try { + const sourceClause = source ? 'AND s.source = ?' : '' + const sourceParams = source ? [source] : [] + const exactPromptSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE m.role = 'user' + AND LOWER(TRIM(m.content)) = ? + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const exactPromptMatch = db.prepare(exactPromptSql).get(...sourceParams, kanbanPrompt) as Record | undefined + if (exactPromptMatch?.id) return String(exactPromptMatch.id) + + const taskJsonSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE LOWER(m.content) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const taskJsonMatch = db.prepare(taskJsonSql).get(...sourceParams, buildLikePattern(taskJsonNeedle)) as Record | undefined + if (taskJsonMatch?.id) return String(taskJsonMatch.id) + + const contentSql = ` + WITH base AS ( + SELECT + ${SESSION_SELECT}, + s.parent_session_id AS parent_session_id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + ) + SELECT base.id + FROM base + JOIN messages m ON m.session_id = base.id + WHERE LOWER(m.content) LIKE ? ESCAPE '\\' + ORDER BY base.last_active DESC, m.timestamp DESC + LIMIT 1 + ` + const contentMatch = db.prepare(contentSql).get(...sourceParams, likePattern) as Record | undefined + if (contentMatch?.id) return String(contentMatch.id) + + const titleSql = ` + SELECT s.id + FROM sessions s + WHERE s.source != 'tool' AND s.id NOT LIKE 'compress_%' + ${sourceClause} + AND LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\' + ORDER BY s.started_at DESC + LIMIT 1 + ` + const titleMatch = db.prepare(titleSql).get(...sourceParams, likePattern) as Record | undefined + return titleMatch?.id ? String(titleMatch.id) : null + } finally { + db.close() + } +} + export interface HermesUsageStats extends LocalUsageStats { cost: number total_api_calls: number diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts index edb4b0d6..49a05253 100644 --- a/tests/server/kanban-controller.test.ts +++ b/tests/server/kanban-controller.test.ts @@ -12,6 +12,8 @@ const mockGetStats = vi.hoisted(() => vi.fn()) const mockGetAssignees = vi.hoisted(() => vi.fn()) const mockSearchSessions = vi.hoisted(() => vi.fn()) const mockGetSessionDetail = vi.hoisted(() => vi.fn()) +const mockGetExactSessionDetail = vi.hoisted(() => vi.fn()) +const mockFindLatestExactSessionId = vi.hoisted(() => vi.fn()) vi.mock('fs/promises', () => ({ readFile: mockReadFile, @@ -36,6 +38,8 @@ vi.mock('../../packages/server/src/services/hermes/hermes-kanban', () => ({ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ searchSessionSummariesWithProfile: mockSearchSessions, getSessionDetailFromDbWithProfile: mockGetSessionDetail, + getExactSessionDetailFromDbWithProfile: mockGetExactSessionDetail, + findLatestExactSessionIdWithProfile: mockFindLatestExactSessionId, })) import * as ctrl from '../../packages/server/src/controllers/hermes/kanban' @@ -71,8 +75,8 @@ describe('kanban controller', () => { comments: [], events: [], }) - mockSearchSessions.mockResolvedValue([{ id: 'session-1' }]) - mockGetSessionDetail.mockResolvedValue({ + mockFindLatestExactSessionId.mockResolvedValue('session-1') + mockGetExactSessionDetail.mockResolvedValue({ title: 'Session one', source: 'codex', model: 'gpt-5.5', @@ -84,11 +88,38 @@ describe('kanban controller', () => { const c = ctx({ params: { id: 'task-1' } }) await ctrl.get(c) - expect(mockSearchSessions).toHaveBeenCalledWith('task-1', 'fresh', undefined, 5) - expect(mockGetSessionDetail).toHaveBeenCalledWith('session-1', 'fresh') + expect(mockFindLatestExactSessionId).toHaveBeenCalledWith('task-1', 'fresh') + expect(mockGetExactSessionDetail).toHaveBeenCalledWith('session-1', 'fresh') expect(c.body.session).toMatchObject({ id: 'session-1', title: 'Session one' }) }) + it('prefers exact kanban-task session matches over later sessions that merely reference the task id', async () => { + mockGetTask.mockResolvedValue({ + task: { id: 't_348bfaaf', status: 'done' }, + runs: [{ profile: 'default' }], + comments: [], + events: [], + }) + mockFindLatestExactSessionId.mockResolvedValue('session_20260508_110903_58e664') + mockGetExactSessionDetail.mockResolvedValue({ + title: 'work kanban task t_348bfaaf', + source: 'codex', + model: 'gpt-5.5', + started_at: 1, + ended_at: 2, + messages: [{ id: 'm1', role: 'user', content: 'work kanban task t_348bfaaf', timestamp: 1 }], + }) + + const c = ctx({ params: { id: 't_348bfaaf' } }) + await ctrl.get(c) + + expect(c.body.session).toMatchObject({ + id: 'session_20260508_110903_58e664', + title: 'work kanban task t_348bfaaf', + }) + expect(c.body.session.messages[0].content).toBe('work kanban task t_348bfaaf') + }) + it('validates create/search/readArtifact requests', async () => { const createCtx = ctx({ request: { body: {} } }) await ctrl.create(createCtx) @@ -113,6 +144,30 @@ describe('kanban controller', () => { mockGetStats.mockResolvedValue({ total: 1, by_status: {}, by_assignee: {} }) mockGetAssignees.mockResolvedValue([{ name: 'alice' }]) mockSearchSessions.mockResolvedValue([{ id: 'session-2' }]) + mockFindLatestExactSessionId.mockResolvedValue('session-2') + mockGetExactSessionDetail.mockResolvedValue({ + id: 'session-2', + source: 'codex', + title: 'Matched session', + preview: 'task-id matched', + model: 'gpt-5.5', + started_at: 100, + ended_at: 101, + last_active: 101, + message_count: 2, + tool_call_count: 0, + input_tokens: 1, + output_tokens: 1, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: null, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: '', + messages: [], + thread_session_count: 1, + }) const fileCtx = ctx({ query: { path: '/Users/tester/.hermes/kanban/workspaces/task/out.txt' } }) await ctrl.readArtifact(fileCtx) @@ -152,5 +207,9 @@ describe('kanban controller', () => { const searchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice', q: 'custom' } }) await ctrl.searchSessions(searchCtx) expect(mockSearchSessions).toHaveBeenCalledWith('custom', 'alice', undefined, 10) + + const exactSearchCtx = ctx({ query: { task_id: 'task-1', profile: 'alice' } }) + await ctrl.searchSessions(exactSearchCtx) + expect(exactSearchCtx.body.results[0]).toMatchObject({ id: 'session-2', title: 'Matched session' }) }) })