2025-08-18 18:39:57 -06:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2025-09-24 15:38:36 -04:00
|
|
|
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
|
2025-08-18 18:39:57 -06:00
|
|
|
import { getProjectHash } from '../utils/paths.js';
|
|
|
|
|
import path from 'node:path';
|
2026-04-09 17:13:55 -04:00
|
|
|
import * as fs from 'node:fs';
|
2026-03-26 23:43:39 -04:00
|
|
|
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
2026-04-09 17:13:55 -04:00
|
|
|
import { isNodeError } from '../utils/errors.js';
|
2026-03-26 23:43:39 -04:00
|
|
|
import {
|
|
|
|
|
deleteSessionArtifactsAsync,
|
|
|
|
|
deleteSubagentSessionDirAndArtifactsAsync,
|
|
|
|
|
} from '../utils/sessionOperations.js';
|
2026-04-09 17:13:55 -04:00
|
|
|
import readline from 'node:readline';
|
2025-08-18 18:39:57 -06:00
|
|
|
import { randomUUID } from 'node:crypto';
|
2025-09-02 23:29:07 -06:00
|
|
|
import type {
|
2026-02-06 16:22:22 -05:00
|
|
|
Content,
|
|
|
|
|
Part,
|
2025-09-02 23:29:07 -06:00
|
|
|
PartListUnion,
|
|
|
|
|
GenerateContentResponseUsageMetadata,
|
|
|
|
|
} from '@google/genai';
|
2025-11-04 16:02:31 -05:00
|
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
2026-03-12 18:56:31 -07:00
|
|
|
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
2026-04-09 17:13:55 -04:00
|
|
|
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';
|
2025-10-06 13:34:00 -06:00
|
|
|
|
2026-01-23 18:28:45 +00:00
|
|
|
/**
|
|
|
|
|
* 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.';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
function hasProperty<T extends string>(
|
|
|
|
|
obj: unknown,
|
|
|
|
|
prop: T,
|
|
|
|
|
): obj is { [key in T]: unknown } {
|
|
|
|
|
return obj !== null && typeof obj === 'object' && prop in obj;
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
function isStringProperty<T extends string>(
|
|
|
|
|
obj: unknown,
|
|
|
|
|
prop: T,
|
|
|
|
|
): obj is { [key in T]: string } {
|
|
|
|
|
return hasProperty(obj, prop) && typeof obj[prop] === 'string';
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
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'
|
|
|
|
|
);
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
function isRewindRecord(record: unknown): record is RewindRecord {
|
|
|
|
|
return isStringProperty(record, '$rewindTo');
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
function isMessageRecord(record: unknown): record is MessageRecord {
|
|
|
|
|
return isStringProperty(record, 'id');
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
function isMetadataUpdateRecord(
|
|
|
|
|
record: unknown,
|
|
|
|
|
): record is MetadataUpdateRecord {
|
|
|
|
|
return isObjectProperty(record, '$set');
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSessionIdRecord(record: unknown): record is { sessionId: string } {
|
|
|
|
|
return isStringProperty(record, 'sessionId');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function loadConversationRecord(
|
|
|
|
|
filePath: string,
|
|
|
|
|
options?: LoadConversationOptions,
|
|
|
|
|
): Promise<
|
|
|
|
|
| (ConversationRecord & {
|
|
|
|
|
messageCount?: number;
|
|
|
|
|
firstUserMessage?: string;
|
|
|
|
|
hasUserOrAssistantMessage?: 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[] = [];
|
|
|
|
|
let firstUserMessageStr: string | undefined;
|
|
|
|
|
let hasUserOrAssistant = false;
|
|
|
|
|
|
|
|
|
|
for await (const line of rl) {
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const record = JSON.parse(line) as unknown;
|
|
|
|
|
if (isRewindRecord(record)) {
|
|
|
|
|
const rewindId = record.$rewindTo;
|
|
|
|
|
if (options?.metadataOnly) {
|
|
|
|
|
const idx = messageIds.indexOf(rewindId);
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
messageIds.splice(idx);
|
|
|
|
|
} else {
|
|
|
|
|
messageIds.length = 0;
|
|
|
|
|
}
|
|
|
|
|
// For metadataOnly we can't perfectly un-track hasUserOrAssistant if it was rewinded,
|
|
|
|
|
// but we can assume false if messageIds is empty.
|
|
|
|
|
if (messageIds.length === 0) hasUserOrAssistant = false;
|
|
|
|
|
} 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)) {
|
|
|
|
|
const id = record.id;
|
|
|
|
|
if (
|
|
|
|
|
hasProperty(record, 'type') &&
|
|
|
|
|
(record.type === 'user' || record.type === 'gemini')
|
|
|
|
|
) {
|
|
|
|
|
hasUserOrAssistant = true;
|
|
|
|
|
}
|
|
|
|
|
// Track message count and first user message
|
|
|
|
|
if (options?.metadataOnly) {
|
|
|
|
|
messageIds.push(id);
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
!firstUserMessageStr &&
|
|
|
|
|
hasProperty(record, 'type') &&
|
|
|
|
|
record['type'] === 'user' &&
|
|
|
|
|
hasProperty(record, 'content') &&
|
|
|
|
|
record['content']
|
|
|
|
|
) {
|
|
|
|
|
// 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)) {
|
|
|
|
|
// Metadata update
|
|
|
|
|
metadata = {
|
|
|
|
|
...metadata,
|
|
|
|
|
...record.$set,
|
|
|
|
|
};
|
|
|
|
|
} else if (isPartialMetadataRecord(record)) {
|
|
|
|
|
// Initial metadata line
|
|
|
|
|
metadata = { ...metadata, ...record };
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore parse errors on individual lines
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!metadata.sessionId || !metadata.projectHash) {
|
|
|
|
|
return await parseLegacyRecordFallback(filePath, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sessionId: metadata.sessionId,
|
|
|
|
|
projectHash: metadata.projectHash,
|
|
|
|
|
startTime: metadata.startTime || new Date().toISOString(),
|
|
|
|
|
lastUpdated: metadata.lastUpdated || new Date().toISOString(),
|
|
|
|
|
summary: metadata.summary,
|
|
|
|
|
directories: metadata.directories,
|
|
|
|
|
kind: metadata.kind,
|
|
|
|
|
messages: Array.from(messagesMap.values()),
|
|
|
|
|
messageCount: options?.metadataOnly
|
|
|
|
|
? messageIds.length
|
|
|
|
|
: messagesMap.size,
|
|
|
|
|
firstUserMessage: firstUserMessageStr,
|
|
|
|
|
hasUserOrAssistantMessage: options?.metadataOnly
|
|
|
|
|
? hasUserOrAssistant
|
|
|
|
|
: Array.from(messagesMap.values()).some(
|
|
|
|
|
(m) => m.type === 'user' || m.type === 'gemini',
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.error('Error loading conversation record from JSONL:', error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class ChatRecordingService {
|
|
|
|
|
private conversationFile: string | null = null;
|
2026-03-06 19:45:36 -08:00
|
|
|
private cachedConversation: ConversationRecord | null = null;
|
2025-08-18 18:39:57 -06:00
|
|
|
private sessionId: string;
|
|
|
|
|
private projectHash: string;
|
2026-02-21 12:41:27 -05:00
|
|
|
private kind?: 'main' | 'subagent';
|
2025-08-18 18:39:57 -06:00
|
|
|
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
|
|
|
|
|
private queuedTokens: TokensSummary | null = null;
|
2026-03-12 18:56:31 -07:00
|
|
|
private context: AgentLoopContext;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-03-12 18:56:31 -07:00
|
|
|
constructor(context: AgentLoopContext) {
|
|
|
|
|
this.context = context;
|
|
|
|
|
this.sessionId = context.promptId;
|
|
|
|
|
this.projectHash = getProjectHash(context.config.getProjectRoot());
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
async initialize(
|
2026-02-21 12:41:27 -05:00
|
|
|
resumedSessionData?: ResumedSessionData,
|
|
|
|
|
kind?: 'main' | 'subagent',
|
2026-04-09 17:13:55 -04:00
|
|
|
): Promise<void> {
|
2025-08-18 18:39:57 -06:00
|
|
|
try {
|
2026-02-21 12:41:27 -05:00
|
|
|
this.kind = kind;
|
2025-08-18 18:39:57 -06:00
|
|
|
if (resumedSessionData) {
|
|
|
|
|
this.conversationFile = resumedSessionData.filePath;
|
|
|
|
|
this.sessionId = resumedSessionData.conversation.sessionId;
|
2026-02-21 12:41:27 -05:00
|
|
|
this.kind = resumedSessionData.conversation.kind;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
// 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');
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
} else {
|
|
|
|
|
// Create new session
|
2026-03-12 18:56:31 -07:00
|
|
|
this.sessionId = this.context.promptId;
|
2026-03-26 23:43:39 -04:00
|
|
|
let chatsDir = path.join(
|
2026-03-12 18:56:31 -07:00
|
|
|
this.context.config.storage.getProjectTempDir(),
|
2025-08-20 10:55:47 +09:00
|
|
|
'chats',
|
|
|
|
|
);
|
2026-03-26 23:43:39 -04:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const timestamp = new Date()
|
|
|
|
|
.toISOString()
|
|
|
|
|
.slice(0, 16)
|
|
|
|
|
.replace(/:/g, '-');
|
2026-03-26 23:43:39 -04:00
|
|
|
const safeSessionId = sanitizeFilenamePart(this.sessionId);
|
|
|
|
|
if (!safeSessionId) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Invalid sessionId after sanitization: ${this.sessionId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let filename: string;
|
|
|
|
|
if (this.kind === 'subagent') {
|
2026-04-09 17:13:55 -04:00
|
|
|
filename = `${safeSessionId}.jsonl`;
|
2026-03-26 23:43:39 -04:00
|
|
|
} else {
|
|
|
|
|
filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice(
|
|
|
|
|
0,
|
|
|
|
|
8,
|
2026-04-09 17:13:55 -04:00
|
|
|
)}.jsonl`;
|
2026-03-26 23:43:39 -04:00
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
this.conversationFile = path.join(chatsDir, filename);
|
|
|
|
|
|
2026-04-01 11:29:38 -04:00
|
|
|
const directories =
|
|
|
|
|
this.kind === 'subagent'
|
|
|
|
|
? [
|
|
|
|
|
...(this.context.config
|
|
|
|
|
.getWorkspaceContext()
|
|
|
|
|
?.getDirectories() ?? []),
|
|
|
|
|
]
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const initialMetadata = {
|
2025-08-18 18:39:57 -06:00
|
|
|
sessionId: this.sessionId,
|
|
|
|
|
projectHash: this.projectHash,
|
|
|
|
|
startTime: new Date().toISOString(),
|
|
|
|
|
lastUpdated: new Date().toISOString(),
|
2026-02-21 12:41:27 -05:00
|
|
|
kind: this.kind,
|
2026-04-09 17:13:55 -04:00
|
|
|
directories,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.appendRecord(initialMetadata);
|
|
|
|
|
this.cachedConversation = {
|
|
|
|
|
...initialMetadata,
|
|
|
|
|
messages: [],
|
|
|
|
|
};
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.queuedThoughts = [];
|
|
|
|
|
this.queuedTokens = null;
|
|
|
|
|
} catch (error) {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (isNodeError(error) && error.code === 'ENOSPC') {
|
2026-01-23 18:28:45 +00:00
|
|
|
this.conversationFile = null;
|
|
|
|
|
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
|
2026-04-09 17:13:55 -04:00
|
|
|
return;
|
2026-01-23 18:28:45 +00:00
|
|
|
}
|
2025-11-04 16:02:31 -05:00
|
|
|
debugLogger.error('Error initializing chat recording service:', error);
|
2025-08-18 18:39:57 -06:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
private getLastMessage(
|
|
|
|
|
conversation: ConversationRecord,
|
|
|
|
|
): MessageRecord | undefined {
|
|
|
|
|
return conversation.messages.at(-1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private newMessage(
|
|
|
|
|
type: ConversationRecordExtra['type'],
|
2025-09-02 23:29:07 -06:00
|
|
|
content: PartListUnion,
|
2026-01-30 10:09:27 -08:00
|
|
|
displayContent?: PartListUnion,
|
2025-08-18 18:39:57 -06:00
|
|
|
): MessageRecord {
|
|
|
|
|
return {
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
type,
|
|
|
|
|
content,
|
2026-01-30 10:09:27 -08:00
|
|
|
displayContent,
|
2025-08-18 18:39:57 -06:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recordMessage(message: {
|
2025-10-06 13:34:00 -06:00
|
|
|
model: string | undefined;
|
2025-08-18 18:39:57 -06:00
|
|
|
type: ConversationRecordExtra['type'];
|
2025-09-02 23:29:07 -06:00
|
|
|
content: PartListUnion;
|
2026-01-30 10:09:27 -08:00
|
|
|
displayContent?: PartListUnion;
|
2025-08-18 18:39:57 -06:00
|
|
|
}): void {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!this.conversationFile || !this.cachedConversation) return;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
const msg = this.newMessage(
|
|
|
|
|
message.type,
|
|
|
|
|
message.content,
|
|
|
|
|
message.displayContent,
|
|
|
|
|
);
|
|
|
|
|
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() });
|
2025-08-18 18:39:57 -06:00
|
|
|
} catch (error) {
|
2025-11-04 16:02:31 -05:00
|
|
|
debugLogger.error('Error saving message to chat history.', error);
|
2025-08-18 18:39:57 -06:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recordThought(thought: ThoughtSummary): void {
|
|
|
|
|
if (!this.conversationFile) return;
|
2026-04-09 17:13:55 -04:00
|
|
|
this.queuedThoughts.push({
|
|
|
|
|
...thought,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
|
2025-09-02 23:29:07 -06:00
|
|
|
recordMessageTokens(
|
|
|
|
|
respUsageMetadata: GenerateContentResponseUsageMetadata,
|
|
|
|
|
): void {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!this.conversationFile || !this.cachedConversation) return;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
try {
|
2025-09-02 23:29:07 -06:00
|
|
|
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,
|
|
|
|
|
};
|
2026-04-09 17:13:55 -04:00
|
|
|
const lastMsg = this.getLastMessage(this.cachedConversation);
|
2026-03-06 19:45:36 -08:00
|
|
|
if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) {
|
|
|
|
|
lastMsg.tokens = tokens;
|
|
|
|
|
this.queuedTokens = null;
|
2026-04-09 17:13:55 -04:00
|
|
|
this.pushMessage(lastMsg);
|
2026-03-06 19:45:36 -08:00
|
|
|
} else {
|
|
|
|
|
this.queuedTokens = tokens;
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
} catch (error) {
|
2025-11-04 16:02:31 -05:00
|
|
|
debugLogger.error(
|
|
|
|
|
'Error updating message tokens in chat history.',
|
|
|
|
|
error,
|
|
|
|
|
);
|
2025-08-18 18:39:57 -06:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-12 15:57:07 -04:00
|
|
|
recordToolCalls(model: string, toolCalls: ToolCallRecord[]): void {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!this.conversationFile || !this.cachedConversation) return;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-03-12 18:56:31 -07:00
|
|
|
const toolRegistry = this.context.toolRegistry;
|
2025-09-02 23:29:07 -06:00
|
|
|
const enrichedToolCalls = toolCalls.map((toolCall) => {
|
|
|
|
|
const toolInstance = toolRegistry.getTool(toolCall.name);
|
|
|
|
|
return {
|
|
|
|
|
...toolCall,
|
|
|
|
|
displayName: toolInstance?.displayName || toolCall.name,
|
2026-03-11 15:38:54 -04:00
|
|
|
description:
|
|
|
|
|
toolCall.description?.trim() || toolInstance?.description || '',
|
2025-09-02 23:29:07 -06:00
|
|
|
renderOutputAsMarkdown: toolInstance?.isOutputMarkdown || false,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
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];
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
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);
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-09 17:13:55 -04:00
|
|
|
|
|
|
|
|
lastMsg.toolCalls = updatedToolCalls;
|
|
|
|
|
this.pushMessage(lastMsg);
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
} catch (error) {
|
2025-11-04 16:02:31 -05:00
|
|
|
debugLogger.error(
|
|
|
|
|
'Error adding tool call to message in chat history.',
|
|
|
|
|
error,
|
|
|
|
|
);
|
2025-08-18 18:39:57 -06:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 12:20:15 -05:00
|
|
|
saveSummary(summary: string): void {
|
|
|
|
|
if (!this.conversationFile) return;
|
|
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
this.updateMetadata({ summary });
|
2025-12-05 12:20:15 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.error('Error saving summary to chat history.', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 00:37:58 +05:30
|
|
|
recordDirectories(directories: readonly string[]): void {
|
|
|
|
|
if (!this.conversationFile) return;
|
|
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
this.updateMetadata({ directories: [...directories] });
|
2026-01-29 00:37:58 +05:30
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.error('Error saving directories to chat history.', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 12:20:15 -05:00
|
|
|
getConversation(): ConversationRecord | null {
|
|
|
|
|
if (!this.conversationFile) return null;
|
2026-04-09 17:13:55 -04:00
|
|
|
return this.cachedConversation;
|
2025-12-05 12:20:15 -05:00
|
|
|
}
|
|
|
|
|
|
2025-12-10 15:44:30 -05:00
|
|
|
getConversationFilePath(): string | null {
|
|
|
|
|
return this.conversationFile;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
/**
|
2026-03-14 16:09:43 -04:00
|
|
|
* 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.
|
2025-08-18 18:39:57 -06:00
|
|
|
*/
|
2026-03-26 23:43:39 -04:00
|
|
|
async deleteSession(sessionIdOrBasename: string): Promise<void> {
|
2025-08-18 18:39:57 -06:00
|
|
|
try {
|
2026-03-12 18:56:31 -07:00
|
|
|
const tempDir = this.context.config.storage.getProjectTempDir();
|
2026-02-06 01:36:42 -05:00
|
|
|
const chatsDir = path.join(tempDir, 'chats');
|
2026-03-14 16:09:43 -04:00
|
|
|
const shortId = this.deriveShortId(sessionIdOrBasename);
|
|
|
|
|
|
2026-03-26 23:43:39 -04:00
|
|
|
// Using stat instead of existsSync for async sanity
|
|
|
|
|
if (!(await fs.promises.stat(chatsDir).catch(() => null))) {
|
2026-03-14 16:09:43 -04:00
|
|
|
return; // Nothing to delete
|
2026-02-06 01:36:42 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const matchingFiles = await this.getMatchingSessionFiles(
|
|
|
|
|
chatsDir,
|
|
|
|
|
shortId,
|
|
|
|
|
);
|
2026-03-14 16:09:43 -04:00
|
|
|
for (const file of matchingFiles) {
|
2026-03-26 23:43:39 -04:00
|
|
|
await this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
|
2026-03-03 09:11:25 -05:00
|
|
|
}
|
2026-03-14 16:09:43 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.error('Error deleting session file.', error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 09:11:25 -05:00
|
|
|
|
2026-03-14 16:09:43 -04:00
|
|
|
private deriveShortId(sessionIdOrBasename: string): string {
|
|
|
|
|
let shortId = sessionIdOrBasename;
|
|
|
|
|
if (sessionIdOrBasename.startsWith(SESSION_FILE_PREFIX)) {
|
2026-04-09 17:13:55 -04:00
|
|
|
const withoutExt = sessionIdOrBasename.replace(/\.jsonl?$/, '');
|
2026-03-14 16:09:43 -04:00
|
|
|
const parts = withoutExt.split('-');
|
|
|
|
|
shortId = parts[parts.length - 1];
|
|
|
|
|
} else if (sessionIdOrBasename.length >= 8) {
|
|
|
|
|
shortId = sessionIdOrBasename.slice(0, 8);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('Invalid sessionId or basename provided for deletion');
|
|
|
|
|
}
|
2026-02-06 01:36:42 -05:00
|
|
|
|
2026-03-14 16:09:43 -04:00
|
|
|
if (shortId.length !== 8) {
|
|
|
|
|
throw new Error('Derived shortId must be exactly 8 characters');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return shortId;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
private async getMatchingSessionFiles(
|
|
|
|
|
chatsDir: string,
|
|
|
|
|
shortId: string,
|
|
|
|
|
): Promise<string[]> {
|
|
|
|
|
const files = await fs.promises.readdir(chatsDir);
|
2026-03-14 16:09:43 -04:00
|
|
|
return files.filter(
|
|
|
|
|
(f) =>
|
2026-04-09 17:13:55 -04:00
|
|
|
f.startsWith(SESSION_FILE_PREFIX) &&
|
|
|
|
|
(f.endsWith(`-${shortId}.json`) || f.endsWith(`-${shortId}.jsonl`)),
|
2026-03-14 16:09:43 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Deletes a single session file and its associated logs, tool-outputs, and directory.
|
|
|
|
|
*/
|
2026-03-26 23:43:39 -04:00
|
|
|
private async deleteSessionAndArtifacts(
|
2026-03-14 16:09:43 -04:00
|
|
|
chatsDir: string,
|
|
|
|
|
file: string,
|
|
|
|
|
tempDir: string,
|
2026-03-26 23:43:39 -04:00
|
|
|
): Promise<void> {
|
2026-03-14 16:09:43 -04:00
|
|
|
const filePath = path.join(chatsDir, file);
|
|
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
const CHUNK_SIZE = 4096;
|
|
|
|
|
const buffer = Buffer.alloc(CHUNK_SIZE);
|
|
|
|
|
let firstLine: string;
|
|
|
|
|
let fd: fs.promises.FileHandle | undefined;
|
|
|
|
|
try {
|
|
|
|
|
fd = await fs.promises.open(filePath, 'r');
|
|
|
|
|
const { bytesRead } = await fd.read(buffer, 0, CHUNK_SIZE, 0);
|
|
|
|
|
if (bytesRead === 0) {
|
|
|
|
|
await fd.close();
|
|
|
|
|
await fs.promises.unlink(filePath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const contentChunk = buffer.toString('utf8', 0, bytesRead);
|
|
|
|
|
const newlineIndex = contentChunk.indexOf('\n');
|
|
|
|
|
firstLine =
|
|
|
|
|
newlineIndex !== -1
|
|
|
|
|
? contentChunk.substring(0, newlineIndex)
|
|
|
|
|
: contentChunk;
|
|
|
|
|
} finally {
|
|
|
|
|
if (fd !== undefined) {
|
|
|
|
|
await fd.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const content = JSON.parse(firstLine) as unknown;
|
2026-03-14 16:09:43 -04:00
|
|
|
|
|
|
|
|
let fullSessionId: string | undefined;
|
2026-04-09 17:13:55 -04:00
|
|
|
if (isSessionIdRecord(content)) {
|
|
|
|
|
fullSessionId = content['sessionId'];
|
2026-02-06 01:36:42 -05:00
|
|
|
}
|
2026-03-03 09:11:25 -05:00
|
|
|
|
2026-03-14 16:09:43 -04:00
|
|
|
// Delete the session file
|
2026-03-26 23:43:39 -04:00
|
|
|
await fs.promises.unlink(filePath);
|
2026-03-14 16:09:43 -04:00
|
|
|
|
|
|
|
|
if (fullSessionId) {
|
2026-03-26 23:43:39 -04:00
|
|
|
// Delegate to shared utility!
|
|
|
|
|
await deleteSessionArtifactsAsync(fullSessionId, tempDir);
|
|
|
|
|
await deleteSubagentSessionDirAndArtifactsAsync(
|
|
|
|
|
fullSessionId,
|
|
|
|
|
chatsDir,
|
|
|
|
|
tempDir,
|
|
|
|
|
);
|
2026-03-03 09:11:25 -05:00
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
} catch (error) {
|
2026-03-14 16:09:43 -04:00
|
|
|
debugLogger.error(`Error deleting associated file ${file}:`, error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 12:10:22 -05:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!this.conversationFile || !this.cachedConversation) return null;
|
|
|
|
|
|
|
|
|
|
const messageIndex = this.cachedConversation.messages.findIndex(
|
2026-01-07 12:10:22 -05:00
|
|
|
(m) => m.id === messageId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (messageIndex === -1) {
|
|
|
|
|
debugLogger.error(
|
|
|
|
|
'Message to rewind to not found in conversation history',
|
|
|
|
|
);
|
2026-04-09 17:13:55 -04:00
|
|
|
return this.cachedConversation;
|
2026-01-07 12:10:22 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
this.cachedConversation.messages = this.cachedConversation.messages.slice(
|
|
|
|
|
0,
|
|
|
|
|
messageIndex,
|
|
|
|
|
);
|
|
|
|
|
this.appendRecord({ $rewindTo: messageId });
|
|
|
|
|
return this.cachedConversation;
|
2026-01-07 12:10:22 -05:00
|
|
|
}
|
2026-02-06 16:22:22 -05:00
|
|
|
|
2026-03-09 11:26:03 -07:00
|
|
|
updateMessagesFromHistory(history: readonly Content[]): void {
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!this.conversationFile || !this.cachedConversation) return;
|
2026-02-06 16:22:22 -05:00
|
|
|
|
|
|
|
|
try {
|
2026-04-09 17:13:55 -04:00
|
|
|
const partsMap = new Map<string, Part[]>();
|
|
|
|
|
for (const content of history) {
|
|
|
|
|
if (content.role === 'user' && content.parts) {
|
|
|
|
|
const callIds = content.parts
|
|
|
|
|
.map((p) => p.functionResponse?.id)
|
|
|
|
|
.filter((id): id is string => !!id);
|
|
|
|
|
|
|
|
|
|
if (callIds.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
let currentCallId = callIds[0];
|
|
|
|
|
for (const part of content.parts) {
|
|
|
|
|
if (part.functionResponse?.id) {
|
|
|
|
|
currentCallId = part.functionResponse.id;
|
|
|
|
|
}
|
2026-02-06 16:22:22 -05:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
if (!partsMap.has(currentCallId)) {
|
|
|
|
|
partsMap.set(currentCallId, []);
|
2026-02-06 16:22:22 -05:00
|
|
|
}
|
2026-04-09 17:13:55 -04:00
|
|
|
partsMap.get(currentCallId)!.push(part);
|
2026-02-06 16:22:22 -05:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-09 17:13:55 -04:00
|
|
|
}
|
2026-02-06 16:22:22 -05:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
for (const message of this.cachedConversation.messages) {
|
|
|
|
|
let msgChanged = false;
|
|
|
|
|
if (message.type === 'gemini' && message.toolCalls) {
|
|
|
|
|
for (const toolCall of message.toolCalls) {
|
|
|
|
|
const newParts = partsMap.get(toolCall.id);
|
|
|
|
|
if (newParts !== undefined) {
|
|
|
|
|
toolCall.result = newParts;
|
|
|
|
|
msgChanged = true;
|
2026-02-06 16:22:22 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-09 17:13:55 -04:00
|
|
|
if (msgChanged) {
|
|
|
|
|
// Push updated message to log
|
|
|
|
|
this.pushMessage(message);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 16:22:22 -05:00
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.error(
|
|
|
|
|
'Error updating conversation history from memory.',
|
|
|
|
|
error,
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
}
|
2026-04-09 17:13:55 -04:00
|
|
|
|
|
|
|
|
async function parseLegacyRecordFallback(
|
|
|
|
|
filePath: string,
|
|
|
|
|
options?: LoadConversationOptions,
|
|
|
|
|
): Promise<
|
|
|
|
|
| (ConversationRecord & {
|
|
|
|
|
messageCount?: number;
|
|
|
|
|
firstUserMessage?: string;
|
|
|
|
|
hasUserOrAssistantMessage?: 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',
|
|
|
|
|
);
|
|
|
|
|
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,
|
|
|
|
|
firstUserMessage: fallbackFirstUserMessageStr,
|
|
|
|
|
hasUserOrAssistantMessage:
|
|
|
|
|
legacyRecord.messages?.some(
|
|
|
|
|
(m) => m.type === 'user' || m.type === 'gemini',
|
|
|
|
|
) || false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...legacyRecord,
|
|
|
|
|
hasUserOrAssistantMessage:
|
|
|
|
|
legacyRecord.messages?.some(
|
|
|
|
|
(m) => m.type === 'user' || m.type === 'gemini',
|
|
|
|
|
) || false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore legacy fallback parse error
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|