diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index fd79f379fd4..d79a03c38e0 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -15,6 +15,7 @@ import { type Config, deleteSessionArtifactsAsync, deleteSubagentSessionDirAndArtifactsAsync, + getSessionMetadataSidecarPath, } from '@google/gemini-cli-core'; import type { Settings, SessionRetentionSettings } from '../config/settings.js'; import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js'; @@ -53,6 +54,20 @@ function isSessionIdRecord(record: unknown): record is { sessionId: string } { return isStringProperty(record, 'sessionId'); } +async function unlinkSidecarIfPresent(jsonlPath: string): Promise { + try { + await fs.unlink(getSessionMetadataSidecarPath(jsonlPath)); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { + debugLogger.warn( + `Failed to remove session metadata sidecar for ${path.basename(jsonlPath)}: ${ + error.message + }`, + ); + } + } +} + /** * Result of session cleanup operation */ @@ -131,7 +146,9 @@ export async function cleanupExpiredSessions( return { ...result, disabled: true }; } - const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); + const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId(), { + lazyMigrate: false, + }); result.scanned = allFiles.length; if (allFiles.length === 0) { @@ -216,6 +233,7 @@ export async function cleanupExpiredSessions( // Delete the session file if (!fullSessionId || fullSessionId !== config.getSessionId()) { await fs.unlink(filePath); + await unlinkSidecarIfPresent(filePath); if (fullSessionId) { await cleanupSessionAndSubagentsAsync(fullSessionId, config); @@ -244,6 +262,7 @@ export async function cleanupExpiredSessions( // Fallback to old logic const sessionPath = path.join(chatsDir, sessionToDelete.fileName); await fs.unlink(sessionPath); + await unlinkSidecarIfPresent(sessionPath); const sessionId = sessionToDelete.sessionInfo?.id; if (sessionId) { diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 0bc1183e710..dc9a9423e03 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -616,6 +616,204 @@ describe('SessionSelector', () => { expect(sessions.length).toBe(1); expect(sessions[0].id).toBe(mainSessionId); }); + + describe('metadata sidecar fast path', () => { + it('listSessions uses the sidecar even when the chat file is unreadable', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const fileBase = `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}`; + const jsonlPath = path.join(chatsDir, `${fileBase}.jsonl`); + const sidecarPath = path.join(chatsDir, `${fileBase}.meta.json`); + + // Empty/unreadable chat content; sidecar is the source of truth here. + await fs.writeFile(jsonlPath, ''); + await fs.writeFile( + sidecarPath, + JSON.stringify({ + version: 1, + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'main', + summary: 'sidecar-summary', + messageCount: 3, + userMessageCount: 2, + hasUserOrAssistantMessage: true, + firstUserMessage: 'sidecar first user msg', + }), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + expect(sessions[0].summary).toBe('sidecar-summary'); + expect(sessions[0].messageCount).toBe(3); + expect(sessions[0].firstUserMessage).toBe('sidecar first user msg'); + }); + + it('falls back to chat-file parsing and backfills the sidecar when missing', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const fileBase = `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}`; + const jsonlPath = path.join(chatsDir, `${fileBase}.jsonl`); + const sidecarPath = path.join(chatsDir, `${fileBase}.meta.json`); + + const header = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'main', + }; + const message = { + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + type: 'user', + content: 'fallback first user', + }; + await fs.writeFile( + jsonlPath, + JSON.stringify(header) + '\n' + JSON.stringify(message) + '\n', + ); + + // No sidecar yet + await expect(fs.stat(sidecarPath)).rejects.toThrow(); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + expect(sessions[0].firstUserMessage).toBe('fallback first user'); + + // Lazy migration should have written the sidecar. + const sidecarContent = JSON.parse( + await fs.readFile(sidecarPath, 'utf8'), + ) as { sessionId: string; messageCount: number; version: number }; + expect(sidecarContent.version).toBe(1); + expect(sidecarContent.sessionId).toBe(sessionId); + expect(sidecarContent.messageCount).toBe(1); + }); + + it('falls back when the sidecar version is unknown', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const fileBase = `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}`; + const jsonlPath = path.join(chatsDir, `${fileBase}.jsonl`); + const sidecarPath = path.join(chatsDir, `${fileBase}.meta.json`); + + const header = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'main', + }; + const message = { + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + type: 'user', + content: 'fallback again', + }; + await fs.writeFile( + jsonlPath, + JSON.stringify(header) + '\n' + JSON.stringify(message) + '\n', + ); + // Sidecar with unknown version — must be ignored. + await fs.writeFile(sidecarPath, JSON.stringify({ version: 999 })); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + expect(sessions.length).toBe(1); + expect(sessions[0].firstUserMessage).toBe('fallback again'); + }); + + it('skips sidecars whose kind is subagent', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const fileBase = `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}`; + const jsonlPath = path.join(chatsDir, `${fileBase}.jsonl`); + const sidecarPath = path.join(chatsDir, `${fileBase}.meta.json`); + await fs.writeFile(jsonlPath, ''); + await fs.writeFile( + sidecarPath, + JSON.stringify({ + version: 1, + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'subagent', + messageCount: 1, + userMessageCount: 1, + hasUserOrAssistantMessage: true, + }), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + expect(sessions.length).toBe(0); + }); + + it('does not surface the sidecar file itself as a session entry', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const fileBase = `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}`; + const jsonlPath = path.join(chatsDir, `${fileBase}.jsonl`); + const sidecarPath = path.join(chatsDir, `${fileBase}.meta.json`); + + const header = { + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'main', + }; + const message = { + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + type: 'user', + content: 'hi', + }; + await fs.writeFile( + jsonlPath, + JSON.stringify(header) + '\n' + JSON.stringify(message) + '\n', + ); + await fs.writeFile( + sidecarPath, + JSON.stringify({ + version: 1, + sessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + kind: 'main', + messageCount: 1, + userMessageCount: 1, + hasUserOrAssistantMessage: true, + firstUserMessage: 'hi', + }), + ); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + // Exactly one entry — the sidecar must not be confused for a chat file. + expect(sessions.length).toBe(1); + expect(sessions[0].fileName).toBe(`${fileBase}.jsonl`); + }); + }); }); describe('extractFirstUserMessage', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 437e32c4657..a300e37472e 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -8,11 +8,14 @@ import { checkExhaustive, partListUnionToString, SESSION_FILE_PREFIX, + SESSION_META_SUFFIX, CoreToolCallStatus, type Storage, type ConversationRecord, type MessageRecord, loadConversationRecord, + readSessionMetadataSidecar, + writeSessionMetadataSidecar, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -237,6 +240,13 @@ export const formatRelativeTime = ( export interface GetSessionOptions { /** Whether to load full message content (needed for search) */ includeFullContent?: boolean; + /** + * Whether to opportunistically write a metadata sidecar for sessions that + * don't have one yet. Defaults to true. Callers that scan the chats dir + * without intending to keep every session (e.g. retention cleanup) should + * pass `false` to avoid creating artifacts for soon-to-be-deleted sessions. + */ + lazyMigrate?: boolean; } /** @@ -254,6 +264,7 @@ export const getAllSessionFiles = async ( .filter( (f) => f.startsWith(SESSION_FILE_PREFIX) && + !f.endsWith(SESSION_META_SUFFIX) && (f.endsWith('.json') || f.endsWith('.jsonl')), ) .sort(); // Sort by filename, which includes timestamp @@ -262,77 +273,13 @@ export const getAllSessionFiles = async ( async (file): Promise => { const filePath = path.join(chatsDir, file); try { - const content = await loadConversationRecord(filePath, { - metadataOnly: !options.includeFullContent, - }); - if (!content) { - return { fileName: file, sessionInfo: null }; - } - - // Validate required fields - if ( - !content.sessionId || - !content.startTime || - !content.lastUpdated - ) { - // Missing required fields - treat as corrupted - return { fileName: file, sessionInfo: null }; - } - - // Skip sessions that only contain system messages (info, error, warning) - if (!content.hasUserOrAssistantMessage) { - return { fileName: file, sessionInfo: null }; - } - - // Skip subagent sessions - these are implementation details of a tool call - // and shouldn't be surfaced for resumption in the main agent history. - if (content.kind === 'subagent') { - return { fileName: file, sessionInfo: null }; - } - - const firstUserMessage = content.firstUserMessage - ? cleanMessage(content.firstUserMessage) - : extractFirstUserMessage(content.messages); - const isCurrentSession = currentSessionId - ? file.includes(currentSessionId.slice(0, 8)) - : false; - - let fullContent: string | undefined; - let messages: - | Array<{ role: 'user' | 'assistant'; content: string }> - | undefined; - - if (options.includeFullContent) { - fullContent = content.messages - .map((msg) => partListUnionToString(msg.content)) - .join(' '); - messages = content.messages.map((msg) => ({ - role: - msg.type === 'user' - ? ('user' as const) - : ('assistant' as const), - content: partListUnionToString(msg.content), - })); - } - - const sessionInfo: SessionInfo = { - id: content.sessionId, - file: file.replace(/\.jsonl?$/, ''), - fileName: file, - startTime: content.startTime, - lastUpdated: content.lastUpdated, - messageCount: content.messageCount ?? content.messages.length, - displayName: content.summary - ? stripUnsafeCharacters(content.summary) - : firstUserMessage, - firstUserMessage, - isCurrentSession, - index: 0, // Will be set after sorting valid sessions - summary: content.summary, - fullContent, - messages, - }; - + const sessionInfo = await buildSessionInfoForFile( + chatsDir, + file, + filePath, + currentSessionId, + options, + ); return { fileName: file, sessionInfo }; } catch { // File is corrupted (can't read or parse JSON) @@ -352,6 +299,111 @@ export const getAllSessionFiles = async ( } }; +const buildSessionInfoForFile = async ( + _chatsDir: string, + file: string, + filePath: string, + currentSessionId: string | undefined, + options: GetSessionOptions, +): Promise => { + const isCurrentSession = currentSessionId + ? file.includes(currentSessionId.slice(0, 8)) + : false; + + // Fast path: a sidecar carries the listing-required fields without needing + // to stream and parse the entire JSONL chat file. + if (!options.includeFullContent) { + const sidecar = await readSessionMetadataSidecar(filePath); + if (sidecar) { + if (sidecar.kind === 'subagent') return null; + if (!sidecar.hasUserOrAssistantMessage) return null; + const firstUserMessage = sidecar.firstUserMessage + ? cleanMessage(sidecar.firstUserMessage) + : ''; + return { + id: sidecar.sessionId, + file: file.replace(/\.jsonl?$/, ''), + fileName: file, + startTime: sidecar.startTime, + lastUpdated: sidecar.lastUpdated, + messageCount: sidecar.messageCount, + displayName: sidecar.summary + ? stripUnsafeCharacters(sidecar.summary) + : firstUserMessage, + firstUserMessage, + isCurrentSession, + index: 0, // Will be set after sorting valid sessions + summary: sidecar.summary, + }; + } + } + + // Fall back to streaming the chat file. Sidecar may be missing (legacy + // sessions), corrupt, version-mismatched, or the caller asked for full + // content (search mode). + const content = await loadConversationRecord(filePath, { + metadataOnly: !options.includeFullContent, + }); + if (!content) return null; + + // Validate required fields + if (!content.sessionId || !content.startTime || !content.lastUpdated) { + return null; + } + + // Skip sessions that only contain system messages (info, error, warning) + if (!content.hasUserOrAssistantMessage) return null; + + // Skip subagent sessions — these are implementation details of a tool call + // and shouldn't be surfaced for resumption in the main agent history. + if (content.kind === 'subagent') return null; + + // Lazy migration: backfill the sidecar so future listings hit the fast + // path. Best-effort; failures are logged inside the helper. Skip when the + // caller opts out (e.g. retention cleanup, which is about to delete some + // of the sessions anyway). + if (!options.includeFullContent && options.lazyMigrate !== false) { + writeSessionMetadataSidecar(filePath, content); + } + + const firstUserMessage = content.firstUserMessage + ? cleanMessage(content.firstUserMessage) + : extractFirstUserMessage(content.messages); + + let fullContent: string | undefined; + let messages: + | Array<{ role: 'user' | 'assistant'; content: string }> + | undefined; + + if (options.includeFullContent) { + fullContent = content.messages + .map((msg) => partListUnionToString(msg.content)) + .join(' '); + messages = content.messages.map((msg) => ({ + role: msg.type === 'user' ? ('user' as const) : ('assistant' as const), + content: partListUnionToString(msg.content), + })); + } + + return { + id: content.sessionId, + file: file.replace(/\.jsonl?$/, ''), + fileName: file, + startTime: content.startTime, + lastUpdated: content.lastUpdated, + messageCount: content.messageCount ?? content.messages.length, + displayName: content.summary + ? stripUnsafeCharacters(content.summary) + : firstUserMessage, + firstUserMessage, + isCurrentSession, + index: 0, // Will be set after sorting valid sessions + summary: content.summary, + fullContent, + messages, + }; +}; + /** * Loads all valid session files from the chats directory and converts them to SessionInfo. * Corrupted files are automatically filtered out. diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 7af8380a5a9..4319c6ab118 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -41,6 +41,9 @@ vi.mock('node:fs', async (importOriginal) => { import { ChatRecordingService, loadConversationRecord, + getSessionMetadataSidecarPath, + readSessionMetadataSidecar, + writeSessionMetadataSidecar, type ConversationRecord, type ToolCallRecord, type MessageRecord, @@ -1315,4 +1318,120 @@ describe('ChatRecordingService', () => { mkdirSyncSpy.mockRestore(); }); }); + + describe('metadata sidecar', () => { + it('writes a sidecar on initialize for a brand new session', async () => { + await chatRecordingService.initialize(); + const sessionFile = chatRecordingService.getConversationFilePath()!; + const sidecarPath = getSessionMetadataSidecarPath(sessionFile); + + expect(fs.existsSync(sidecarPath)).toBe(true); + const sidecar = await readSessionMetadataSidecar(sessionFile); + expect(sidecar).not.toBeNull(); + expect(sidecar!.sessionId).toBe('test-session-id'); + expect(sidecar!.messageCount).toBe(0); + expect(sidecar!.hasUserOrAssistantMessage).toBe(false); + }); + + it('rewrites the sidecar when a message is appended', async () => { + await chatRecordingService.initialize(); + const sessionFile = chatRecordingService.getConversationFilePath()!; + + chatRecordingService.recordMessage({ + type: 'user', + content: 'first user message', + model: 'm', + }); + + const sidecar = await readSessionMetadataSidecar(sessionFile); + expect(sidecar).not.toBeNull(); + expect(sidecar!.messageCount).toBe(1); + expect(sidecar!.userMessageCount).toBe(1); + expect(sidecar!.hasUserOrAssistantMessage).toBe(true); + expect(sidecar!.firstUserMessage).toBe('first user message'); + }); + + it('rewrites the sidecar when summary metadata changes', async () => { + await chatRecordingService.initialize(); + const sessionFile = chatRecordingService.getConversationFilePath()!; + + chatRecordingService.saveSummary('a short summary'); + + const sidecar = await readSessionMetadataSidecar(sessionFile); + expect(sidecar).not.toBeNull(); + expect(sidecar!.summary).toBe('a short summary'); + }); + + it('removes the sidecar alongside the chat file on deleteSession', async () => { + const shortId = 'sdcr1234'; + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + + const sessionFile = path.join( + chatsDir, + `session-2023-01-01T00-00-${shortId}.jsonl`, + ); + fs.writeFileSync( + sessionFile, + JSON.stringify({ + sessionId: 'sdcr-session', + projectHash: 'p', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + }) + '\n', + ); + writeSessionMetadataSidecar(sessionFile, { + sessionId: 'sdcr-session', + projectHash: 'p', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messages: [], + }); + + const sidecarPath = getSessionMetadataSidecarPath(sessionFile); + expect(fs.existsSync(sidecarPath)).toBe(true); + + await chatRecordingService.deleteSession(shortId); + + expect(fs.existsSync(sessionFile)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it('removes the sidecar on deleteCurrentSessionAsync', async () => { + await chatRecordingService.initialize(); + const sessionFile = chatRecordingService.getConversationFilePath()!; + const sidecarPath = getSessionMetadataSidecarPath(sessionFile); + expect(fs.existsSync(sidecarPath)).toBe(true); + + await chatRecordingService.deleteCurrentSessionAsync(); + + expect(fs.existsSync(sessionFile)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it('readSessionMetadataSidecar returns null for missing or malformed files', async () => { + const chatsDir = path.join(testTempDir, 'chats'); + fs.mkdirSync(chatsDir, { recursive: true }); + const sessionFile = path.join(chatsDir, 'session-missing.jsonl'); + + expect(await readSessionMetadataSidecar(sessionFile)).toBeNull(); + + // Malformed + const badPath = getSessionMetadataSidecarPath(sessionFile); + fs.writeFileSync(badPath, 'not json'); + expect(await readSessionMetadataSidecar(sessionFile)).toBeNull(); + + // Wrong version + fs.writeFileSync(badPath, JSON.stringify({ version: 99 })); + expect(await readSessionMetadataSidecar(sessionFile)).toBeNull(); + }); + }); + + describe('loadConversationRecord missing file', () => { + it('returns null without throwing when the file does not exist', async () => { + const missing = path.join(testTempDir, 'nope.jsonl'); + const result = await loadConversationRecord(missing); + expect(result).toBeNull(); + }); + }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index e070a1c5420..6b0f92f27ef 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -116,12 +116,15 @@ export async function loadConversationRecord( }) | null > { - if (!fs.existsSync(filePath)) { - return null; + let fileStream: fs.ReadStream; + try { + fileStream = fs.createReadStream(filePath); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; } try { - const fileStream = fs.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, @@ -295,11 +298,183 @@ export async function loadConversationRecord( : hasUserOrAssistant, }; } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; debugLogger.error('Error loading conversation record from JSONL:', error); return null; } } +/** + * Suffix for the per-session metadata sidecar file. The sidecar lives next to + * the JSONL chat file and contains only the fields needed to render the + * session list, so listings can avoid streaming and parsing the full chat. + */ +export const SESSION_META_SUFFIX = '.meta.json'; + +const SESSION_META_VERSION = 1; + +interface SessionMetadataSidecar { + version: typeof SESSION_META_VERSION; + sessionId: string; + projectHash: string; + startTime: string; + lastUpdated: string; + kind?: 'main' | 'subagent'; + summary?: string; + directories?: string[]; + messageCount: number; + userMessageCount: number; + hasUserOrAssistantMessage: boolean; + firstUserMessage?: string; +} + +export function getSessionMetadataSidecarPath(jsonlPath: string): string { + return jsonlPath.replace(/\.jsonl?$/, '') + SESSION_META_SUFFIX; +} + +function extractFirstUserMessageText( + messages: MessageRecord[], +): string | undefined { + for (const msg of messages) { + if (msg.type !== 'user') continue; + const c = msg.content; + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + return c.map((p) => (isTextPart(p) ? p.text : '')).join(''); + } + return undefined; + } + return undefined; +} + +type ConversationRecordWithCounts = ConversationRecord & { + messageCount?: number; + userMessageCount?: number; + firstUserMessage?: string; + hasUserOrAssistantMessage?: boolean; +}; + +function buildSidecarFromConversation( + record: ConversationRecordWithCounts, +): SessionMetadataSidecar { + // Prefer precomputed counts when present (e.g. when the record came from + // loadConversationRecord with metadataOnly:true, which strips `messages` + // but populates the count fields). + let messageCount = record.messageCount ?? record.messages.length; + let userCount = record.userMessageCount; + let hasUserOrAssistant = record.hasUserOrAssistantMessage; + if ( + userCount === undefined || + hasUserOrAssistant === undefined || + record.messages.length > 0 + ) { + let computedUser = 0; + let computedHasUserOrAssistant = false; + for (const msg of record.messages) { + if (msg.type === 'user') computedUser++; + if (msg.type === 'user' || msg.type === 'gemini') { + computedHasUserOrAssistant = true; + } + } + if (record.messages.length > 0) { + userCount = computedUser; + hasUserOrAssistant = computedHasUserOrAssistant; + messageCount = record.messages.length; + } else { + userCount = userCount ?? computedUser; + hasUserOrAssistant = hasUserOrAssistant ?? computedHasUserOrAssistant; + } + } + const firstUserMessage = + record.firstUserMessage ?? extractFirstUserMessageText(record.messages); + return { + version: SESSION_META_VERSION, + sessionId: record.sessionId, + projectHash: record.projectHash, + startTime: record.startTime, + lastUpdated: record.lastUpdated, + kind: record.kind, + summary: record.summary, + directories: record.directories ? [...record.directories] : undefined, + messageCount, + userMessageCount: userCount ?? 0, + hasUserOrAssistantMessage: hasUserOrAssistant ?? false, + firstUserMessage, + }; +} + +/** + * Atomically writes the sidecar metadata file for the session at `jsonlPath`. + * Sidecars are derivable from the chat file, so write failures are swallowed + * (ENOSPC) or logged but never thrown — listings fall back to parsing the + * chat file when the sidecar is missing. + */ +export function writeSessionMetadataSidecar( + jsonlPath: string, + conversation: ConversationRecordWithCounts, +): void { + const sidecar = buildSidecarFromConversation(conversation); + const finalPath = getSessionMetadataSidecarPath(jsonlPath); + const tmpPath = finalPath + '.tmp'; + try { + fs.writeFileSync(tmpPath, JSON.stringify(sidecar)); + fs.renameSync(tmpPath, finalPath); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOSPC') return; + debugLogger.error('Error writing session metadata sidecar:', error); + } +} + +/** + * Reads the metadata sidecar for the JSONL chat file. Returns null if the + * sidecar is missing, malformed, or has an unknown version. Callers must + * fall back to parsing the JSONL chat file in that case. + */ +function isNumberProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: number } { + return hasProperty(obj, prop) && typeof obj[prop] === 'number'; +} + +function isBooleanProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: boolean } { + return hasProperty(obj, prop) && typeof obj[prop] === 'boolean'; +} + +function isSessionMetadataSidecar( + value: unknown, +): value is SessionMetadataSidecar { + return ( + hasProperty(value, 'version') && + value.version === SESSION_META_VERSION && + isStringProperty(value, 'sessionId') && + isStringProperty(value, 'projectHash') && + isStringProperty(value, 'startTime') && + isStringProperty(value, 'lastUpdated') && + isNumberProperty(value, 'messageCount') && + isNumberProperty(value, 'userMessageCount') && + isBooleanProperty(value, 'hasUserOrAssistantMessage') + ); +} + +export async function readSessionMetadataSidecar( + jsonlPath: string, +): Promise { + const sidecarPath = getSessionMetadataSidecarPath(jsonlPath); + try { + const content = await fs.promises.readFile(sidecarPath, 'utf8'); + const parsed: unknown = JSON.parse(content); + return isSessionMetadataSidecar(parsed) ? parsed : null; + } catch { + return null; + } +} + +export type { SessionMetadataSidecar }; + export class ChatRecordingService { private conversationFile: string | null = null; private cachedConversation: ConversationRecord | null = null; @@ -362,6 +537,7 @@ export class ChatRecordingService { // Update the session ID in the existing file this.updateMetadata({ sessionId: this.sessionId }); + this.writeSidecar(); } else { throw new Error('Failed to load resumed session data from file'); } @@ -433,6 +609,7 @@ export class ChatRecordingService { ...initialMetadata, messages: [], }; + this.writeSidecar(); } this.queuedThoughts = []; @@ -464,10 +641,16 @@ export class ChatRecordingService { } } + private writeSidecar(): void { + if (!this.conversationFile || !this.cachedConversation) return; + writeSessionMetadataSidecar(this.conversationFile, this.cachedConversation); + } + private updateMetadata(updates: Partial): void { if (!this.cachedConversation) return; Object.assign(this.cachedConversation, updates); this.appendRecord({ $set: updates }); + this.writeSidecar(); } private pushMessage(msg: MessageRecord): void { @@ -485,6 +668,7 @@ export class ChatRecordingService { } else { this.cachedConversation.messages.push(msg); } + this.writeSidecar(); } private getLastMessage( @@ -814,6 +998,18 @@ export class ChatRecordingService { debugLogger.error(`Error unlinking session file ${file}:`, error); } } + + // Best-effort removal of the metadata sidecar. + try { + await fs.promises.unlink(getSessionMetadataSidecarPath(filePath)); + } catch (error) { + if (isNodeError(error) && error.code !== 'ENOENT') { + debugLogger.error( + `Error unlinking session metadata sidecar for ${file}:`, + error, + ); + } + } } } @@ -835,6 +1031,13 @@ export class ChatRecordingService { // File may not exist; ignore. }); + // Best-effort removal of the metadata sidecar. + await fs.promises + .unlink(getSessionMetadataSidecarPath(this.conversationFile)) + .catch(() => { + // File may not exist; ignore. + }); + // Delegate tool-output and log cleanup to the shared utility. await deleteSessionArtifactsAsync(this.sessionId, tempDir); } catch (error) { @@ -866,6 +1069,7 @@ export class ChatRecordingService { messageIndex, ); this.appendRecord({ $rewindTo: messageId }); + this.writeSidecar(); return this.cachedConversation; }