import fs from 'node:fs'; import { DEFAULT_MAX_MESSAGE_CONTENT_LENGTH } from '../constants.js'; import { DatabaseManager } from './db.js'; import { parseSessionFile, getSessionFiles, type ParsedSession } from './session-parser.js'; export const LAST_SESSION_BACKFILL_KEY = 'last_session_backfill'; export const SESSION_BACKFILL_INTERVAL_MS = 24 * 60 * 60 * 1000; /** * Index result for a single session. */ export interface IndexResult { sessionId: string; messagesIndexed: number; skipped: boolean; // true if the session already existed and no new messages were indexed } /** * Bulk index result. */ export interface BulkIndexResult { sessionsProcessed: number; sessionsIndexed: number; sessionsSkipped: number; messagesIndexed: number; errors: string[]; reachedLimit?: boolean; } interface SessionFileMetadata { path: string; size: number; mtimeMs: number; } export interface IncrementalIndexOptions { projectDir?: string; maxFilesToIndex?: number; } export function truncateMessageContent( content: string, maxLength = DEFAULT_MAX_MESSAGE_CONTENT_LENGTH, ): string { if (content.length <= maxLength) return content; const notice = `\n... (truncated, ${content.length} chars total)\n`; const retainedLength = Math.max(0, maxLength - notice.length); const prefixLength = Math.ceil(retainedLength / 2); const suffixLength = Math.floor(retainedLength / 2); const suffix = suffixLength > 0 ? content.slice(-suffixLength) : ''; return `${content.slice(0, prefixLength)}${notice}${suffix}`; } /** * Index a single session into the database. * * @returns IndexResult with count of messages indexed */ export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult { return dbManager.withCorruptionRecovery(() => indexSessionOnce(dbManager, session)); } function indexSessionOnce(dbManager: DatabaseManager, session: ParsedSession): IndexResult { const db = dbManager.getDb(); const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined; const before = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number }; const insertSession = db.prepare(` INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count) VALUES (?, ?, ?, ?, ?, ?) `); const insertMsg = db.prepare(` INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls) VALUES (?, ?, ?, ?, ?, ?) `); const updateSession = db.prepare(` UPDATE sessions SET project = ?, cwd = ?, ended_at = COALESCE(?, ended_at), message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?) WHERE id = ? `); const writeSession = () => { insertSession.run( session.id, session.project, session.cwd, session.startedAt, session.endedAt, session.messages.length ); for (const msg of session.messages) { insertMsg.run( msg.id, session.id, msg.role, truncateMessageContent(msg.content), msg.timestamp, msg.toolCalls ? JSON.stringify(msg.toolCalls) : null ); } updateSession.run(session.project, session.cwd, session.endedAt, session.id, session.id); }; if (db.transaction) { const tx = db.transaction(writeSession); tx(); } else { writeSession(); } const after = db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?').get(session.id) as { count: number }; const messagesIndexed = after.count - before.count; return { sessionId: session.id, messagesIndexed, skipped: Boolean(existingSession) && messagesIndexed === 0 }; } type SessionManagerSnapshot = { getHeader: () => { id: string; timestamp: string; cwd: string } | null; getEntries: () => unknown[]; getSessionFile?: () => string | undefined; }; type SessionMessageEntryLike = { type?: unknown; id?: unknown; timestamp?: unknown; message?: { role?: unknown; content?: unknown; }; }; function extractTextContent(content: unknown): string { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; const parts: string[] = []; for (const block of content) { if (!block || typeof block !== 'object') continue; const b = block as Record; switch (b.type) { case 'text': if (typeof b.text === 'string') parts.push(b.text); break; case 'tool_result': // Tool results can contain unbounded file or command output. Tool // calls are indexed separately, so retaining their output adds bloat // without improving session search. break; } } return parts.join('\n').trim(); } function extractToolCalls(content: unknown): string[] | undefined { if (!Array.isArray(content)) return undefined; const toolNames: string[] = []; for (const block of content) { if (!block || typeof block !== 'object') continue; const b = block as Record; if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string') { toolNames.push(b.name); } } return toolNames.length > 0 ? toolNames : undefined; } function parseMessageEntry(entry: unknown): ParsedSession['messages'][number] | null { if (!entry || typeof entry !== 'object') return null; const e = entry as SessionMessageEntryLike; if (e.type !== 'message' || typeof e.id !== 'string' || typeof e.timestamp !== 'string' || !e.message) return null; const role = e.message.role; if (role !== 'user' && role !== 'assistant' && role !== 'system') return null; const content = extractTextContent(e.message.content); if (!content) return null; return { id: e.id, role, content, timestamp: e.timestamp, toolCalls: role === 'assistant' ? extractToolCalls(e.message.content) : undefined, }; } export function parseSessionManagerSnapshot(sessionManager: SessionManagerSnapshot): ParsedSession | null { const header = sessionManager.getHeader(); if (!header?.id || !header.cwd || !header.timestamp) return null; const messages = sessionManager.getEntries() .map(parseMessageEntry) .filter((msg): msg is ParsedSession['messages'][number] => msg !== null); return { id: header.id, project: header.cwd.split('/').pop() ?? header.cwd, cwd: header.cwd, startedAt: header.timestamp, endedAt: null, messages, }; } export function indexCurrentSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null { const session = parseSessionManagerSnapshot(sessionManager); if (!session) return null; return indexSession(dbManager, session); } export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null { return dbManager.withCorruptionRecovery(() => indexLiveSessionOnce(dbManager, sessionManager)); } function indexLiveSessionOnce(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null { const sessionFile = sessionManager.getSessionFile?.(); if (sessionManager.getSessionFile && !sessionFile) return null; if (sessionFile && fs.existsSync(sessionFile)) { const session = parseSessionFile(sessionFile); if (session) { const result = indexSession(dbManager, session); upsertSessionFileMetadata(dbManager, sessionFile, session.id); return result; } } return indexCurrentSession(dbManager, sessionManager); } /** * Remove rows created by background review subprocesses that ran with * `--no-session` before live indexing rejected ephemeral sessions. */ export function pruneEphemeralReviewSessions(dbManager: DatabaseManager): number { return dbManager.withCorruptionRecovery(() => { const db = dbManager.getDb(); const candidates = db.prepare(` SELECT s.id FROM sessions s WHERE NOT EXISTS ( SELECT 1 FROM session_files sf WHERE sf.session_id = s.id ) AND (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) = 1 AND EXISTS ( SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.content LIKE ? ) `).all('