Files
gemini-cli/packages/core/src/services/chatRecordingService.ts
T
2026-06-09 22:41:47 +00:00

1027 lines
32 KiB
TypeScript

/**
* @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 {
throw new Error('Failed to load resumed session data from file');
}
} 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;
}
}
}
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;
}