Download packages/core/src/services/chatRecordingService.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 35.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/chatRecordingService.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/services/chatRecordingService.ts
-
curl -L -o chatRecordingService.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/chatRecordingService.ts
35.4 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { type ThoughtSummary } from '../utils/thoughtUtils.js'; | |
| import { getProjectHash } from '../utils/paths.js'; | |
| import path from 'node:path'; | |
| import * as fs from 'node:fs'; | |
| import { sanitizeFilenamePart } from '../utils/fileUtils.js'; | |
| import { isNodeError } from '../utils/errors.js'; | |
| import { | |
| deleteSessionArtifactsAsync, | |
| deleteStoredSession, | |
| } from '../utils/sessionOperations.js'; | |
| import readline from 'node:readline'; | |
| import { randomUUID } from 'node:crypto'; | |
| import type { | |
| PartListUnion, | |
| GenerateContentResponseUsageMetadata, | |
| } from '@google/genai'; | |
| import { debugLogger } from '../utils/debugLogger.js'; | |
| import type { AgentLoopContext } from '../config/agent-loop-context.js'; | |
| import type { HistoryTurn } from '../core/agentChatHistory.js'; | |
| import { partListUnionToString } from '../core/geminiRequest.js'; | |
| import { isIgnoredUserContent } from '../utils/sessionUtils.js'; | |
| import { | |
| SESSION_FILE_PREFIX, | |
| type TokensSummary, | |
| type ToolCallRecord, | |
| type ConversationRecordExtra, | |
| type MessageRecord, | |
| type ConversationRecord, | |
| type ResumedSessionData, | |
| type LoadConversationOptions, | |
| type RewindRecord, | |
| type MetadataUpdateRecord, | |
| type PartialMetadataRecord, | |
| } from './chatRecordingTypes.js'; | |
| export * from './chatRecordingTypes.js'; | |
| /** | |
| * Warning message shown when recording is disabled due to disk full. | |
| */ | |
| const ENOSPC_WARNING_MESSAGE = | |
| 'Chat recording disabled: No space left on device. ' + | |
| 'The conversation will continue but will not be saved to disk. ' + | |
| 'Free up disk space and restart to enable recording.'; | |
| function hasProperty<T extends string>( | |
| obj: unknown, | |
| prop: T, | |
| ): obj is { [key in T]: unknown } { | |
| return obj !== null && typeof obj === 'object' && prop in obj; | |
| } | |
| function isStringProperty<T extends string>( | |
| obj: unknown, | |
| prop: T, | |
| ): obj is { [key in T]: string } { | |
| return hasProperty(obj, prop) && typeof obj[prop] === 'string'; | |
| } | |
| function isObjectProperty<T extends string>( | |
| obj: unknown, | |
| prop: T, | |
| ): obj is { [key in T]: object } { | |
| return ( | |
| hasProperty(obj, prop) && | |
| obj[prop] !== null && | |
| typeof obj[prop] === 'object' | |
| ); | |
| } | |
| function isRewindRecord(record: unknown): record is RewindRecord { | |
| return isStringProperty(record, '$rewindTo'); | |
| } | |
| function isMessageRecord(record: unknown): record is MessageRecord { | |
| return isStringProperty(record, 'id'); | |
| } | |
| function isMetadataUpdateRecord( | |
| record: unknown, | |
| ): record is MetadataUpdateRecord { | |
| return isObjectProperty(record, '$set'); | |
| } | |
| function isPartialMetadataRecord( | |
| record: unknown, | |
| ): record is PartialMetadataRecord { | |
| return ( | |
| isStringProperty(record, 'sessionId') && | |
| isStringProperty(record, 'projectHash') | |
| ); | |
| } | |
| function isTextPart(part: unknown): part is { text: string } { | |
| return isStringProperty(part, 'text'); | |
| } | |
| /** | |
| * Returns true when a stored message represents conversation content worth | |
| * surfacing in resume flows. | |
| */ | |
| export function isResumableMessageRecord(message: MessageRecord): boolean { | |
| const contentString = message.content | |
| ? partListUnionToString(message.content) | |
| : ''; | |
| if (message.type === 'user') { | |
| return !isIgnoredUserContent(contentString.trim()); | |
| } | |
| if (message.type === 'gemini') { | |
| return ( | |
| contentString.trim().length > 0 || | |
| (message.toolCalls?.length ?? 0) > 0 || | |
| (message.thoughts?.length ?? 0) > 0 | |
| ); | |
| } | |
| return false; | |
| } | |
| export function hasResumableConversationContent( | |
| messages: readonly MessageRecord[], | |
| ): boolean { | |
| return messages.some((message) => isResumableMessageRecord(message)); | |
| } | |
| export async function loadConversationRecord( | |
| filePath: string, | |
| options?: LoadConversationOptions, | |
| ): Promise< | |
| | (ConversationRecord & { | |
| messageCount?: number; | |
| userMessageCount?: number; | |
| firstUserMessage?: string; | |
| hasResumableContent?: boolean; | |
| memoryScratchpadIsStale?: boolean; | |
| }) | |
| | null | |
| > { | |
| if (!fs.existsSync(filePath)) { | |
| return null; | |
| } | |
| try { | |
| const fileStream = fs.createReadStream(filePath); | |
| const rl = readline.createInterface({ | |
| input: fileStream, | |
| crlfDelay: Infinity, | |
| }); | |
| let metadata: Partial<ConversationRecord> = {}; | |
| const messagesMap = new Map<string, MessageRecord>(); | |
| const messageIds: string[] = []; | |
| const messageKinds = new Map< | |
| string, | |
| { isUser: boolean; isResumable: boolean } | |
| >(); | |
| let isTrackingMemoryScratchpadFreshness = false; | |
| let memoryScratchpadIsStale = false; | |
| let firstUserMessageStr: string | undefined; | |
| for await (const line of rl) { | |
| if (!line.trim()) continue; | |
| try { | |
| const record = JSON.parse(line) as unknown; | |
| if (isRewindRecord(record)) { | |
| if (isTrackingMemoryScratchpadFreshness) { | |
| memoryScratchpadIsStale = true; | |
| } | |
| const rewindId = record.$rewindTo; | |
| if (options?.metadataOnly) { | |
| const idx = messageIds.indexOf(rewindId); | |
| if (idx !== -1) { | |
| const removedIds = messageIds.splice(idx); | |
| for (const removedId of removedIds) { | |
| messageKinds.delete(removedId); | |
| } | |
| } else { | |
| messageIds.length = 0; | |
| messageKinds.clear(); | |
| } | |
| } else { | |
| let found = false; | |
| const idsToDelete: string[] = []; | |
| for (const [id] of messagesMap) { | |
| if (id === rewindId) found = true; | |
| if (found) idsToDelete.push(id); | |
| } | |
| if (found) { | |
| for (const id of idsToDelete) { | |
| messagesMap.delete(id); | |
| } | |
| } else { | |
| messagesMap.clear(); | |
| } | |
| } | |
| } else if (isMessageRecord(record)) { | |
| if (isTrackingMemoryScratchpadFreshness) { | |
| memoryScratchpadIsStale = true; | |
| } | |
| const id = record.id; | |
| const isUser = hasProperty(record, 'type') && record.type === 'user'; | |
| const isResumable = isResumableMessageRecord(record); | |
| // Track message count and first user message | |
| if (options?.metadataOnly) { | |
| messageIds.push(id); | |
| messageKinds.set(id, { isUser, isResumable }); | |
| } | |
| if ( | |
| !firstUserMessageStr && | |
| isUser && | |
| hasProperty(record, 'content') && | |
| record['content'] && | |
| isResumable | |
| ) { | |
| // Basic extraction of first user message for display | |
| const rawContent = record['content']; | |
| if (Array.isArray(rawContent)) { | |
| firstUserMessageStr = rawContent | |
| .map((p: unknown) => (isTextPart(p) ? p['text'] : '')) | |
| .join(''); | |
| } else if (typeof rawContent === 'string') { | |
| firstUserMessageStr = rawContent; | |
| } | |
| } | |
| if (!options?.metadataOnly) { | |
| messagesMap.set(id, record); | |
| if ( | |
| options?.maxMessages && | |
| messagesMap.size > options.maxMessages | |
| ) { | |
| const firstKey = messagesMap.keys().next().value; | |
| if (typeof firstKey === 'string') messagesMap.delete(firstKey); | |
| } | |
| } | |
| } else if (isMetadataUpdateRecord(record)) { | |
| if (hasProperty(record.$set, 'memoryScratchpad')) { | |
| isTrackingMemoryScratchpadFreshness = Boolean( | |
| record.$set.memoryScratchpad, | |
| ); | |
| memoryScratchpadIsStale = false; | |
| } | |
| if ( | |
| hasProperty(record.$set, 'messages') && | |
| Array.isArray(record.$set.messages) | |
| ) { | |
| // Checkpoint: clear and rebuild from the provided messages array | |
| messagesMap.clear(); | |
| if (options?.metadataOnly) { | |
| messageIds.length = 0; | |
| messageKinds.clear(); | |
| } | |
| for (const msg of record.$set.messages) { | |
| if (isMessageRecord(msg)) { | |
| const id = msg.id; | |
| const isUser = msg.type === 'user'; | |
| const isResumable = isResumableMessageRecord(msg); | |
| if (options?.metadataOnly) { | |
| messageIds.push(id); | |
| messageKinds.set(id, { | |
| isUser, | |
| isResumable, | |
| }); | |
| } else { | |
| messagesMap.set(id, msg); | |
| } | |
| if ( | |
| !firstUserMessageStr && | |
| isUser && | |
| isResumable && | |
| msg.content && | |
| (Array.isArray(msg.content) || | |
| typeof msg.content === 'string') | |
| ) { | |
| if (Array.isArray(msg.content)) { | |
| firstUserMessageStr = msg.content | |
| .map((p: unknown) => (isTextPart(p) ? p.text : '')) | |
| .join(''); | |
| } else { | |
| firstUserMessageStr = msg.content; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Metadata update | |
| metadata = { | |
| ...metadata, | |
| ...record.$set, | |
| }; | |
| } else if (isPartialMetadataRecord(record)) { | |
| // Initial metadata line (or entire legacy record if on one line) | |
| metadata = { ...metadata, ...record }; | |
| if ( | |
| hasProperty(record, 'messages') && | |
| Array.isArray(record.messages) | |
| ) { | |
| for (const msg of record.messages) { | |
| if (isMessageRecord(msg)) { | |
| const id = msg.id; | |
| const isUser = msg.type === 'user'; | |
| const isResumable = isResumableMessageRecord(msg); | |
| if (options?.metadataOnly) { | |
| messageIds.push(id); | |
| messageKinds.set(id, { | |
| isUser, | |
| isResumable, | |
| }); | |
| } else { | |
| messagesMap.set(id, msg); | |
| } | |
| if ( | |
| !firstUserMessageStr && | |
| isUser && | |
| isResumable && | |
| msg.content && | |
| (Array.isArray(msg.content) || | |
| typeof msg.content === 'string') | |
| ) { | |
| if (Array.isArray(msg.content)) { | |
| firstUserMessageStr = msg.content | |
| .map((p: unknown) => (isTextPart(p) ? p.text : '')) | |
| .join(''); | |
| } else { | |
| firstUserMessageStr = msg.content; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch { | |
| // ignore parse errors on individual lines | |
| } | |
| } | |
| if (!metadata.sessionId || !metadata.projectHash) { | |
| return await parseLegacyRecordFallback(filePath, options); | |
| } | |
| const loadedMessages = Array.from(messagesMap.values()); | |
| const metadataFirstUserMessage = | |
| loadedMessages.find( | |
| (message) => | |
| message.type === 'user' && isResumableMessageRecord(message), | |
| ) ?? null; | |
| let fallbackFirstUserMessage = firstUserMessageStr; | |
| if (!fallbackFirstUserMessage && metadataFirstUserMessage) { | |
| const rawContent = metadataFirstUserMessage.content; | |
| if (Array.isArray(rawContent)) { | |
| fallbackFirstUserMessage = rawContent | |
| .map((part: unknown) => (isTextPart(part) ? part['text'] : '')) | |
| .join(''); | |
| } else if (typeof rawContent === 'string') { | |
| fallbackFirstUserMessage = rawContent; | |
| } | |
| } | |
| const userMessageCount = options?.metadataOnly | |
| ? Array.from(messageKinds.values()).filter((m) => m.isUser).length | |
| : loadedMessages.filter((m) => m.type === 'user').length; | |
| const hasResumableContent = options?.metadataOnly | |
| ? Array.from(messageKinds.values()).some((m) => m.isResumable) | |
| : hasResumableConversationContent(loadedMessages); | |
| return { | |
| sessionId: metadata.sessionId, | |
| projectHash: metadata.projectHash, | |
| startTime: metadata.startTime || new Date().toISOString(), | |
| lastUpdated: metadata.lastUpdated || new Date().toISOString(), | |
| summary: metadata.summary, | |
| memoryScratchpad: metadata.memoryScratchpad, | |
| directories: metadata.directories, | |
| kind: metadata.kind, | |
| messages: options?.metadataOnly ? [] : loadedMessages, | |
| messageCount: options?.metadataOnly | |
| ? loadedMessages.length || messageIds.length | |
| : loadedMessages.length, | |
| userMessageCount, | |
| memoryScratchpadIsStale: isTrackingMemoryScratchpadFreshness | |
| ? memoryScratchpadIsStale | |
| : undefined, | |
| firstUserMessage: fallbackFirstUserMessage, | |
| hasResumableContent, | |
| }; | |
| } catch (error) { | |
| debugLogger.error('Error loading conversation record from JSONL:', error); | |
| return null; | |
| } | |
| } | |
| export class ChatRecordingService { | |
| private conversationFile: string | null = null; | |
| private cachedConversation: ConversationRecord | null = null; | |
| private sessionId: string; | |
| private projectHash: string; | |
| private kind?: 'main' | 'subagent'; | |
| private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = []; | |
| private queuedTokens: TokensSummary | null = null; | |
| private context: AgentLoopContext; | |
| constructor(context: AgentLoopContext) { | |
| this.context = context; | |
| this.sessionId = context.promptId; | |
| this.projectHash = getProjectHash(context.config.getProjectRoot()); | |
| } | |
| async initialize( | |
| resumedSessionData?: ResumedSessionData, | |
| kind?: 'main' | 'subagent', | |
| ): Promise<void> { | |
| try { | |
| this.kind = kind; | |
| if (resumedSessionData) { | |
| this.conversationFile = resumedSessionData.filePath; | |
| this.sessionId = resumedSessionData.conversation.sessionId; | |
| this.kind = resumedSessionData.conversation.kind; | |
| const loadedRecord = await loadConversationRecord( | |
| this.conversationFile, | |
| ); | |
| if (loadedRecord) { | |
| this.cachedConversation = loadedRecord; | |
| this.projectHash = this.cachedConversation.projectHash; | |
| if (this.conversationFile.endsWith('.json')) { | |
| this.conversationFile = this.conversationFile + 'l'; // e.g. session-foo.jsonl | |
| // Migrate the entire legacy record to the new file | |
| const initialMetadata = { | |
| sessionId: this.sessionId, | |
| projectHash: this.projectHash, | |
| startTime: this.cachedConversation.startTime, | |
| lastUpdated: this.cachedConversation.lastUpdated, | |
| kind: this.cachedConversation.kind, | |
| directories: this.cachedConversation.directories, | |
| summary: this.cachedConversation.summary, | |
| }; | |
| this.appendRecord(initialMetadata); | |
| for (const msg of this.cachedConversation.messages) { | |
| this.appendRecord(msg); | |
| } | |
| if (this.cachedConversation.memoryScratchpad) { | |
| this.appendRecord({ | |
| $set: { | |
| memoryScratchpad: this.cachedConversation.memoryScratchpad, | |
| }, | |
| }); | |
| } | |
| } | |
| // Update the session ID in the existing file | |
| this.updateMetadata({ sessionId: this.sessionId }); | |
| } else { | |
| // The file could not be reloaded (missing, corrupt metadata, or an | |
| // I/O error). Fall back to the in-memory conversation we were handed | |
| // rather than failing the caller, and rewrite a clean file from it. | |
| debugLogger.warn( | |
| 'Failed to reload resumed session data from file; falling back ' + | |
| 'to the in-memory conversation.', | |
| ); | |
| this.cachedConversation = resumedSessionData.conversation; | |
| this.projectHash = this.cachedConversation.projectHash; | |
| this.rewriteConversationFile(this.cachedConversation); | |
| } | |
| } else { | |
| // Create new session | |
| this.sessionId = this.context.promptId; | |
| let chatsDir = path.join( | |
| this.context.config.storage.getProjectTempDir(), | |
| 'chats', | |
| ); | |
| // subagents are nested under the complete parent session id | |
| if (this.kind === 'subagent' && this.context.parentSessionId) { | |
| const safeParentId = sanitizeFilenamePart( | |
| this.context.parentSessionId, | |
| ); | |
| if (!safeParentId) { | |
| throw new Error( | |
| `Invalid parentSessionId after sanitization: ${this.context.parentSessionId}`, | |
| ); | |
| } | |
| chatsDir = path.join(chatsDir, safeParentId); | |
| } | |
| fs.mkdirSync(chatsDir, { recursive: true }); | |
| const timestamp = new Date() | |
| .toISOString() | |
| .slice(0, 16) | |
| .replace(/:/g, '-'); | |
| const safeSessionId = sanitizeFilenamePart(this.sessionId); | |
| if (!safeSessionId) { | |
| throw new Error( | |
| `Invalid sessionId after sanitization: ${this.sessionId}`, | |
| ); | |
| } | |
| let filename: string; | |
| if (this.kind === 'subagent') { | |
| filename = `${safeSessionId}.jsonl`; | |
| } else { | |
| filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice( | |
| 0, | |
| 8, | |
| )}.jsonl`; | |
| } | |
| this.conversationFile = path.join(chatsDir, filename); | |
| const directories = | |
| this.kind === 'subagent' | |
| ? [ | |
| ...(this.context.config | |
| .getWorkspaceContext() | |
| ?.getDirectories() ?? []), | |
| ] | |
| : undefined; | |
| const initialMetadata = { | |
| sessionId: this.sessionId, | |
| projectHash: this.projectHash, | |
| startTime: new Date().toISOString(), | |
| lastUpdated: new Date().toISOString(), | |
| kind: this.kind, | |
| directories, | |
| }; | |
| this.appendRecord(initialMetadata); | |
| this.cachedConversation = { | |
| ...initialMetadata, | |
| messages: [], | |
| }; | |
| } | |
| this.queuedThoughts = []; | |
| this.queuedTokens = null; | |
| } catch (error) { | |
| if (isNodeError(error) && error.code === 'ENOSPC') { | |
| this.conversationFile = null; | |
| debugLogger.warn(ENOSPC_WARNING_MESSAGE); | |
| return; | |
| } | |
| debugLogger.error('Error initializing chat recording service:', error); | |
| throw error; | |
| } | |
| } | |
| private appendRecord(record: unknown): void { | |
| if (!this.conversationFile) return; | |
| try { | |
| const line = JSON.stringify(record) + '\n'; | |
| fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); | |
| fs.appendFileSync(this.conversationFile, line); | |
| } catch (error) { | |
| if (isNodeError(error) && error.code === 'ENOSPC') { | |
| this.conversationFile = null; | |
| debugLogger.warn(ENOSPC_WARNING_MESSAGE); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| /** | |
| * Rewrites the session file from an in-memory record. Any existing | |
| * (unreadable) file is preserved alongside rather than destroyed, and the | |
| * new file is written atomically (temp file + rename). | |
| */ | |
| private rewriteConversationFile(conversation: ConversationRecord): void { | |
| if (!this.conversationFile) return; | |
| // Normalize legacy `.json` paths to the `.jsonl` format we write. | |
| if (this.conversationFile.endsWith('.json')) { | |
| this.conversationFile = this.conversationFile + 'l'; | |
| } | |
| const { messages, memoryScratchpad, ...metadata } = conversation; | |
| const lines: string[] = [JSON.stringify(metadata)]; | |
| for (const msg of messages) { | |
| lines.push(JSON.stringify(msg)); | |
| } | |
| if (memoryScratchpad) { | |
| lines.push(JSON.stringify({ $set: { memoryScratchpad } })); | |
| } | |
| const content = lines.join('\n') + '\n'; | |
| try { | |
| fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true }); | |
| // The existing file was unreadable, but it may have been only | |
| // transiently so (a lock or I/O blip) rather than truly corrupt. Keep | |
| // its bytes rather than destroying them. | |
| if (fs.existsSync(this.conversationFile)) { | |
| const backup = `${this.conversationFile}.unreadable-${Date.now()}`; | |
| try { | |
| fs.renameSync(this.conversationFile, backup); | |
| debugLogger.warn( | |
| `Preserved the unreadable session file at ${backup}.`, | |
| ); | |
| } catch (backupError) { | |
| debugLogger.error( | |
| 'Failed to preserve the unreadable session file.', | |
| backupError, | |
| ); | |
| } | |
| } | |
| const tempFile = `${this.conversationFile}.tmp-${process.pid}`; | |
| try { | |
| fs.writeFileSync(tempFile, content); | |
| fs.renameSync(tempFile, this.conversationFile); | |
| } catch (error) { | |
| // The rename did not complete, so the temp file would be left behind. | |
| try { | |
| fs.unlinkSync(tempFile); | |
| } catch { | |
| // Ignore cleanup errors so the original failure still surfaces. | |
| } | |
| throw error; | |
| } | |
| } catch (error) { | |
| if (isNodeError(error) && error.code === 'ENOSPC') { | |
| this.conversationFile = null; | |
| debugLogger.warn(ENOSPC_WARNING_MESSAGE); | |
| } else { | |
| throw error; | |
| } | |
| } | |
| } | |
| private updateMetadata(updates: Partial<ConversationRecord>): void { | |
| if (!this.cachedConversation) return; | |
| Object.assign(this.cachedConversation, updates); | |
| this.appendRecord({ $set: updates }); | |
| } | |
| private pushMessage(msg: MessageRecord): void { | |
| if (!this.cachedConversation) return; | |
| // We append the full message to the log | |
| this.appendRecord(msg); | |
| // Now update memory | |
| const index = this.cachedConversation.messages.findIndex( | |
| (m) => m.id === msg.id, | |
| ); | |
| if (index !== -1) { | |
| this.cachedConversation.messages[index] = msg; | |
| } else { | |
| this.cachedConversation.messages.push(msg); | |
| } | |
| } | |
| private getLastMessage( | |
| conversation: ConversationRecord, | |
| ): MessageRecord | undefined { | |
| return conversation.messages.at(-1); | |
| } | |
| private newMessage( | |
| type: ConversationRecordExtra['type'], | |
| content: PartListUnion, | |
| displayContent?: PartListUnion, | |
| id?: string, | |
| ): MessageRecord { | |
| return { | |
| id: id || randomUUID(), | |
| timestamp: new Date().toISOString(), | |
| type, | |
| content, | |
| displayContent, | |
| }; | |
| } | |
| recordMessage(message: { | |
| model: string | undefined; | |
| type: ConversationRecordExtra['type']; | |
| content: PartListUnion; | |
| displayContent?: PartListUnion; | |
| id?: string; | |
| }): string { | |
| if (!this.conversationFile || !this.cachedConversation) | |
| return message.id || randomUUID(); | |
| try { | |
| const msg = this.newMessage( | |
| message.type, | |
| message.content, | |
| message.displayContent, | |
| message.id, | |
| ); | |
| if (msg.type === 'gemini') { | |
| msg.thoughts = this.queuedThoughts; | |
| msg.tokens = this.queuedTokens; | |
| msg.model = message.model; | |
| this.queuedThoughts = []; | |
| this.queuedTokens = null; | |
| } | |
| this.pushMessage(msg); | |
| this.updateMetadata({ lastUpdated: new Date().toISOString() }); | |
| return msg.id; | |
| } catch (error) { | |
| debugLogger.error('Error saving message to chat history.', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Records a synthetic message (e.g. Binary Received, Snapshot/Summary) | |
| * and returns its durable ID. | |
| */ | |
| recordSyntheticMessage( | |
| type: ConversationRecordExtra['type'], | |
| content: PartListUnion, | |
| id?: string, | |
| ): string { | |
| return this.recordMessage({ | |
| model: undefined, | |
| type, | |
| content, | |
| id, | |
| }); | |
| } | |
| recordThought(thought: ThoughtSummary): void { | |
| if (!this.conversationFile) return; | |
| this.queuedThoughts.push({ | |
| ...thought, | |
| timestamp: new Date().toISOString(), | |
| }); | |
| } | |
| recordMessageTokens( | |
| respUsageMetadata: GenerateContentResponseUsageMetadata, | |
| ): void { | |
| if (!this.conversationFile || !this.cachedConversation) return; | |
| try { | |
| const tokens = { | |
| input: respUsageMetadata.promptTokenCount ?? 0, | |
| output: respUsageMetadata.candidatesTokenCount ?? 0, | |
| cached: respUsageMetadata.cachedContentTokenCount ?? 0, | |
| thoughts: respUsageMetadata.thoughtsTokenCount ?? 0, | |
| tool: respUsageMetadata.toolUsePromptTokenCount ?? 0, | |
| total: respUsageMetadata.totalTokenCount ?? 0, | |
| }; | |
| const lastMsg = this.getLastMessage(this.cachedConversation); | |
| if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) { | |
| lastMsg.tokens = tokens; | |
| this.queuedTokens = null; | |
| this.pushMessage(lastMsg); | |
| } else { | |
| this.queuedTokens = tokens; | |
| } | |
| } catch (error) { | |
| debugLogger.error( | |
| 'Error updating message tokens in chat history.', | |
| error, | |
| ); | |
| throw error; | |
| } | |
| } | |
| recordToolCalls(model: string, toolCalls: ToolCallRecord[]): void { | |
| if (!this.conversationFile || !this.cachedConversation) return; | |
| const toolRegistry = this.context.toolRegistry; | |
| const enrichedToolCalls = toolCalls.map((toolCall) => { | |
| const toolInstance = toolRegistry.getTool(toolCall.name); | |
| return { | |
| ...toolCall, | |
| displayName: toolInstance?.displayName || toolCall.name, | |
| description: | |
| toolCall.description?.trim() || toolInstance?.description || '', | |
| renderOutputAsMarkdown: toolInstance?.isOutputMarkdown || false, | |
| }; | |
| }); | |
| try { | |
| const lastMsg = this.getLastMessage(this.cachedConversation); | |
| if ( | |
| !lastMsg || | |
| lastMsg.type !== 'gemini' || | |
| this.queuedThoughts.length > 0 | |
| ) { | |
| const newMsg: MessageRecord = { | |
| ...this.newMessage('gemini' as const, ''), | |
| type: 'gemini' as const, | |
| toolCalls: enrichedToolCalls, | |
| thoughts: this.queuedThoughts, | |
| model, | |
| }; | |
| if (this.queuedThoughts.length > 0) { | |
| newMsg.thoughts = this.queuedThoughts; | |
| this.queuedThoughts = []; | |
| } | |
| if (this.queuedTokens) { | |
| newMsg.tokens = this.queuedTokens; | |
| this.queuedTokens = null; | |
| } | |
| this.pushMessage(newMsg); | |
| } else { | |
| if (!lastMsg.toolCalls) { | |
| lastMsg.toolCalls = []; | |
| } | |
| // Deep clone toolCalls to avoid modifying memory references directly | |
| const updatedToolCalls = [...lastMsg.toolCalls]; | |
| for (const toolCall of enrichedToolCalls) { | |
| const index = updatedToolCalls.findIndex( | |
| (tc) => tc.id === toolCall.id, | |
| ); | |
| if (index !== -1) { | |
| updatedToolCalls[index] = { | |
| ...updatedToolCalls[index], | |
| ...toolCall, | |
| }; | |
| } else { | |
| updatedToolCalls.push(toolCall); | |
| } | |
| } | |
| lastMsg.toolCalls = updatedToolCalls; | |
| this.pushMessage(lastMsg); | |
| } | |
| } catch (error) { | |
| debugLogger.error( | |
| 'Error adding tool call to message in chat history.', | |
| error, | |
| ); | |
| throw error; | |
| } | |
| } | |
| saveSummary(summary: string): void { | |
| if (!this.conversationFile) return; | |
| try { | |
| this.updateMetadata({ summary }); | |
| } catch (error) { | |
| debugLogger.error('Error saving summary to chat history.', error); | |
| } | |
| } | |
| recordDirectories(directories: readonly string[]): void { | |
| if (!this.conversationFile) return; | |
| try { | |
| this.updateMetadata({ directories: [...directories] }); | |
| } catch (error) { | |
| debugLogger.error('Error saving directories to chat history.', error); | |
| } | |
| } | |
| getConversation(): ConversationRecord | null { | |
| if (!this.conversationFile) return null; | |
| return this.cachedConversation; | |
| } | |
| getConversationFilePath(): string | null { | |
| return this.conversationFile; | |
| } | |
| /** | |
| * Deletes a session file by sessionId, filename, or basename. | |
| * Derives an 8-character shortId to find and delete all associated files | |
| * (parent and subagents). | |
| * | |
| * @throws {Error} If shortId validation fails. | |
| */ | |
| async deleteSession(sessionIdOrBasename: string): Promise<void> { | |
| return deleteStoredSession(this.context.config, sessionIdOrBasename); | |
| } | |
| /** | |
| * Asynchronously deletes the current session's chat file and tool outputs. | |
| * This encapsulates the session ID logic and uses non-blocking I/O to avoid | |
| * blocking the event loop on exit. | |
| */ | |
| async deleteCurrentSessionAsync(): Promise<void> { | |
| if (!this.conversationFile) { | |
| return; | |
| } | |
| try { | |
| const tempDir = this.context.config.storage.getProjectTempDir(); | |
| // Delete the conversation file directly using the tracked path. | |
| await fs.promises.unlink(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) { | |
| debugLogger.error('Error deleting current session.', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Deletes the current session only if it has no resumable conversation | |
| * content. This removes abandoned startup-only sessions while preserving any | |
| * session with a real user prompt, model response, or tool activity. | |
| */ | |
| async deleteCurrentSessionIfNotResumableAsync(): Promise<void> { | |
| if (!this.conversationFile || !this.cachedConversation) { | |
| return; | |
| } | |
| if (hasResumableConversationContent(this.cachedConversation.messages)) { | |
| return; | |
| } | |
| await this.deleteCurrentSessionAsync(); | |
| } | |
| /** | |
| * Rewinds the conversation to the state just before the specified message ID. | |
| * All messages from (and including) the specified ID onwards are removed. | |
| */ | |
| rewindTo(messageId: string): ConversationRecord | null { | |
| if (!this.conversationFile || !this.cachedConversation) return null; | |
| const messageIndex = this.cachedConversation.messages.findIndex( | |
| (m) => m.id === messageId, | |
| ); | |
| if (messageIndex === -1) { | |
| debugLogger.error( | |
| 'Message to rewind to not found in conversation history', | |
| ); | |
| return this.cachedConversation; | |
| } | |
| this.cachedConversation.messages = this.cachedConversation.messages.slice( | |
| 0, | |
| messageIndex, | |
| ); | |
| this.appendRecord({ $rewindTo: messageId }); | |
| return this.cachedConversation; | |
| } | |
| updateMessagesFromHistory(history: readonly HistoryTurn[]): void { | |
| if (!this.conversationFile || !this.cachedConversation) return; | |
| try { | |
| let updated = false; | |
| // 1. Sync content and IDs | |
| const newMessages: MessageRecord[] = history.map((turn) => { | |
| const existing = this.cachedConversation?.messages.find( | |
| (m) => m.id === turn.id, | |
| ); | |
| if (existing) { | |
| // If content parts have changed (e.g. masking), update them | |
| if ( | |
| JSON.stringify(existing.content) !== | |
| JSON.stringify(turn.content.parts) | |
| ) { | |
| updated = true; | |
| } | |
| return { | |
| ...existing, | |
| content: turn.content.parts || [], | |
| }; | |
| } | |
| // It's a new (possibly synthetic) turn like a summary | |
| updated = true; | |
| return this.newMessage( | |
| turn.content.role === 'user' ? 'user' : 'gemini', | |
| turn.content.parts || [], | |
| undefined, | |
| turn.id, | |
| ); | |
| }); | |
| // 2. Specialized 'Masking Sync' for tool call results | |
| // If a user turn in history contains a functionResponse, we update the | |
| // corresponding ToolCallRecord in the preceding gemini message. | |
| for (const turn of history) { | |
| if (turn.content.role !== 'user') continue; | |
| for (const part of turn.content.parts || []) { | |
| if (part.functionResponse) { | |
| const callId = part.functionResponse.id; | |
| // Find the gemini message that contains this tool call | |
| const geminiMsg = newMessages.find( | |
| (m) => | |
| m.type === 'gemini' && | |
| m.toolCalls?.some((tc) => tc.id === callId), | |
| ); | |
| if (geminiMsg && geminiMsg.type === 'gemini') { | |
| const tc = geminiMsg.toolCalls!.find((tc) => tc.id === callId); | |
| if (tc) { | |
| // If the history version is different (e.g. masked), sync it into the record | |
| // We sync the entire parts array of the user turn to ensure sibling parts are preserved | |
| if ( | |
| JSON.stringify(tc.result) !== | |
| JSON.stringify(turn.content.parts) | |
| ) { | |
| tc.result = turn.content.parts || []; | |
| updated = true; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if ( | |
| updated || | |
| newMessages.length !== this.cachedConversation.messages.length | |
| ) { | |
| this.cachedConversation.messages = newMessages; | |
| this.updateMetadata({ | |
| messages: newMessages, | |
| lastUpdated: new Date().toISOString(), | |
| }); | |
| } | |
| } catch (error) { | |
| debugLogger.error( | |
| 'Error updating conversation history from memory.', | |
| error, | |
| ); | |
| throw error; | |
| } | |
| } | |
| } | |
| async function parseLegacyRecordFallback( | |
| filePath: string, | |
| options?: LoadConversationOptions, | |
| ): Promise< | |
| | (ConversationRecord & { | |
| messageCount?: number; | |
| userMessageCount?: number; | |
| firstUserMessage?: string; | |
| hasResumableContent?: boolean; | |
| }) | |
| | null | |
| > { | |
| try { | |
| const fileContent = await fs.promises.readFile(filePath, 'utf8'); | |
| const parsed = JSON.parse(fileContent) as unknown; | |
| const isLegacyRecord = (val: unknown): val is ConversationRecord => | |
| typeof val === 'object' && val !== null && 'sessionId' in val; | |
| if (isLegacyRecord(parsed)) { | |
| const legacyRecord = parsed; | |
| if (options?.metadataOnly) { | |
| let fallbackFirstUserMessageStr: string | undefined; | |
| const firstUserMessage = legacyRecord.messages?.find( | |
| (m) => m.type === 'user' && isResumableMessageRecord(m), | |
| ); | |
| if (firstUserMessage) { | |
| const rawContent = firstUserMessage.content; | |
| if (Array.isArray(rawContent)) { | |
| fallbackFirstUserMessageStr = rawContent | |
| .map((p: unknown) => (isTextPart(p) ? p['text'] : '')) | |
| .join(''); | |
| } else if (typeof rawContent === 'string') { | |
| fallbackFirstUserMessageStr = rawContent; | |
| } | |
| } | |
| return { | |
| ...legacyRecord, | |
| messages: [], | |
| messageCount: legacyRecord.messages?.length || 0, | |
| userMessageCount: | |
| legacyRecord.messages?.filter((m) => m.type === 'user').length || 0, | |
| firstUserMessage: fallbackFirstUserMessageStr, | |
| hasResumableContent: | |
| legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) || | |
| false, | |
| }; | |
| } | |
| return { | |
| ...legacyRecord, | |
| userMessageCount: | |
| legacyRecord.messages?.filter((m) => m.type === 'user').length || 0, | |
| hasResumableContent: | |
| legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) || | |
| false, | |
| }; | |
| } | |
| } catch { | |
| // ignore legacy fallback parse error | |
| } | |
| return null; | |
| } | |