// src/tools/chat/export.ts // Copyright (C) 2025 DTP Technologies, LLC // All Rights Reserved import fs from "node:fs/promises"; import path from "node:path"; import type { ToolDefinition } from "../../lib/ai-client.js"; import type { IUser } from "../../models/user.js"; import { DtpTool, type ToolArguments, type ToolContext, } from "../../lib/tool.js"; import { ChatHistory } from "../../models/chat-history.js"; import { ChatSession, ChatSessionMode } from "../../models/chat-session.js"; type ExportFormat = "json" | "markdown" | "text"; interface IChatToolCallParameter { name: string; value: string; } interface IChatFileOperation { type: "read" | "write" | "edit" | "shell"; path?: string; diff?: string; linesAdded?: number; linesRemoved?: number; isBinary?: boolean; } interface IChatToolCall { tool: { name: string; callId: string; parameters: IChatToolCallParameter[]; }; response: string; fileOperation?: IChatFileOperation; subagentStats?: { inputTokens: number; outputTokens: number; toolCallCount: number; }; } interface IChatHistoryExport { createdAt: Date; prompt: string; mode: string; toolCalls: IChatToolCall[]; fileOperations: IChatFileOperation[]; response?: { thinking?: string; message?: string; }; qdrantId?: string; status: "success" | "failed"; isSubagent?: boolean; error?: { message: string; stack?: string; timestamp: Date; }; subagentHistory?: IChatHistoryExport[]; } interface RawChatHistoryItem { createdAt: Date | string; prompt: string | undefined; mode: string | undefined; toolCalls: unknown; fileOperations: unknown; response: { thinking?: string; message?: string } | undefined; qdrantId: string | undefined; status: "success" | "failed" | undefined; isSubagent: boolean | undefined; error?: { message: string; stack?: string; timestamp: Date }; subagentHistory?: RawChatHistoryItem[]; } export class ChatExportTool extends DtpTool { get name(): string { return "ChatExportTool"; } get slug(): string { return "chat-export"; } get metadata() { return { name: this.definition.function.name || "chat_export", category: "chat", tags: ["export", "session", "download", "backup"], modes: [ ChatSessionMode.Plan, ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop, ], }; } public definition: ToolDefinition = { type: "function", function: { name: "chat_export", description: "Export the conversation from the current or specified session to JSON, Markdown, or plain text format. When filename is provided, writes the export to a file in the current working directory. Returns the formatted content and file information.", parameters: { type: "object", properties: { session_id: { type: "string", description: "Optional: Session ID to export. If omitted, exports the current session.", }, format: { type: "string", enum: ["json", "markdown", "text"], description: "Export format: 'json' for structured data, 'markdown' for readable docs, 'text' for plain text.", }, filename: { type: "string", description: "Optional: Filename to write the export to. When provided, the export will be written to a file in the current working directory. The file will be created or overwritten if it exists.", }, }, required: ["format"], }, }, }; public async execute( context: ToolContext, args: ToolArguments, ): Promise { const { session_id, format, filename } = args; const user = context.session.user as IUser; const userId = user._id.toHexString(); const currentSessionId = context.session._id.toHexString(); const exportFormat = (format as ExportFormat) || "text"; if (!["json", "markdown", "text"].includes(exportFormat)) { return this.error( "INVALID_PARAMETER", `Invalid format: ${exportFormat}. Must be 'json', 'markdown', or 'text'.`, { parameter: "format" }, ); } const targetSessionId = (session_id as string | undefined) || currentSessionId; if (!targetSessionId) { return this.error( "MISSING_SESSION", "No session ID provided and no current session context.", ); } try { const session = await ChatSession.findOne({ _id: targetSessionId, user: userId, }); if (!session) { return this.error( "NOT_FOUND", `Session not found: ${targetSessionId}`, { recoveryHint: "Verify the session ID exists and belongs to the current user.", }, ); } const history = await ChatHistory.find({ session: targetSessionId, status: "success", }) .sort({ createdAt: 1 }) .lean(); if (history.length === 0) { const result = { content: "", turnCount: 0, format: exportFormat, filename: undefined as string | undefined, }; if (filename && typeof filename === "string") { result.filename = filename; } return this.success( result, "No conversation history found in this session.", ); } const enhancedHistory = await this.enhanceHistoryWithSubagents( history as RawChatHistoryItem[], ); const exported = this.formatExport( session, enhancedHistory, exportFormat, ); let result: { content: string; turnCount: number; format: ExportFormat; filename?: string; filePath?: string; byteCount?: number; } = { content: exported, turnCount: enhancedHistory.length, format: exportFormat, }; if (filename && typeof filename === "string") { const filePath = path.resolve(filename); await fs.writeFile(filePath, exported, "utf-8"); const byteCount = Buffer.byteLength(exported, "utf-8"); result.filename = filename; result.filePath = filePath; result.byteCount = byteCount; } return this.success( result, filename && typeof filename === "string" ? `Export written to ${filename} (${result.byteCount} bytes)` : `Exported ${result.turnCount} turns in ${exportFormat} format`, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.log.error("Failed to export session", { sessionId: targetSessionId, error: errorMessage, }); return this.error( "OPERATION_FAILED", `Failed to export session: ${errorMessage}`, { recoveryHint: "Try a different format or check the session exists.", }, ); } } private async enhanceHistoryWithSubagents( history: RawChatHistoryItem[], ): Promise { const enhanced: IChatHistoryExport[] = []; for (const item of history) { const entry: IChatHistoryExport = { createdAt: new Date(item.createdAt), prompt: (item.prompt as string) || "", mode: (item.mode as string) || "", toolCalls: (item.toolCalls as IChatToolCall[]) || [], fileOperations: (item.fileOperations as IChatFileOperation[]) || [], response: item.response, qdrantId: (item.qdrantId as string) || undefined, status: (item.status as "success" | "failed") || "success", isSubagent: (item.isSubagent as boolean) || false, error: item.error, }; // Handle subagent history recursively if (item.subagentHistory && Array.isArray(item.subagentHistory)) { entry.subagentHistory = await this.enhanceHistoryWithSubagents( item.subagentHistory as RawChatHistoryItem[], ); } enhanced.push(entry); } return enhanced; } private formatExport( session: { _id: unknown; name: string; createdAt: Date; type: string }, history: IChatHistoryExport[], format: ExportFormat, ): string { switch (format) { case "json": return this.formatAsJson(session, history); case "markdown": return this.formatAsMarkdown(session, history); case "text": default: return this.formatAsText(session, history); } } private formatAsJson( session: { _id: unknown; name: string; createdAt: Date; type: string }, history: IChatHistoryExport[], ): string { const data = { session: { id: (session._id as any).toString(), name: session.name, type: session.type, createdAt: session.createdAt.toISOString(), }, turns: history.map((turn, index) => ({ turnNumber: index + 1, timestamp: turn.createdAt.toISOString(), mode: turn.mode, user: turn.prompt, assistant: turn.response?.message || "", thinking: turn.response?.thinking || undefined, toolCalls: turn.toolCalls.map((tc) => ({ tool: { name: tc.tool?.name || "", callId: tc.tool?.callId || "", parameters: tc.tool?.parameters || [], }, response: tc.response || "", fileOperation: tc.fileOperation, subagentStats: tc.subagentStats, })), fileOperations: turn.fileOperations, status: turn.status, isSubagent: turn.isSubagent || false, error: turn.error, subagentHistory: turn.subagentHistory?.map((sh) => ({ turnNumber: 0, timestamp: sh.createdAt.toISOString(), mode: sh.mode, user: sh.prompt, assistant: sh.response?.message || "", thinking: sh.response?.thinking || undefined, toolCalls: sh.toolCalls.map((tc) => ({ tool: { name: tc.tool?.name || "", callId: tc.tool?.callId || "", parameters: tc.tool?.parameters || [], }, response: tc.response || "", fileOperation: tc.fileOperation, subagentStats: tc.subagentStats, })), fileOperations: sh.fileOperations, status: sh.status, isSubagent: sh.isSubagent || false, error: sh.error, })), })), }; return JSON.stringify(data, null, 2); } private formatAsMarkdown( session: { _id: unknown; name: string; createdAt: Date; type: string }, history: IChatHistoryExport[], ): string { let md = `# ${session.name}\n\n`; md += `**Session ID:** ${(session._id as any).toString()}\n`; md += `**Type:** ${session.type}\n`; md += `**Created:** ${session.createdAt.toISOString()}\n`; md += `**Turns:** ${history.length}\n\n`; md += `---\n\n`; for (const [i, turn] of history.entries()) { md += `## Turn ${i + 1}\n\n`; md += `**Timestamp:** ${turn.createdAt.toISOString()}\n`; md += `**Mode:** ${turn.mode}\n`; md += `**Status:** ${turn.status}\n`; md += `**Subagent:** ${turn.isSubagent ? "Yes" : "No"}\n\n`; md += `### User\n\n${turn.prompt}\n\n`; if (turn.response?.thinking) { md += `### Thinking\n\n${turn.response.thinking}\n\n`; } if (turn.response?.message) { md += `### Assistant\n\n${turn.response.message}\n\n`; } if (turn.toolCalls && turn.toolCalls.length > 0) { md += `### Tool Calls\n\n`; for (const tc of turn.toolCalls) { md += `#### Tool Call: ${tc.tool?.name || "unknown"}\n\n`; md += `**Call ID:** ${tc.tool?.callId || ""}\n\n`; if (tc.tool?.parameters && tc.tool.parameters.length > 0) { md += `**Parameters:**\n\n`; for (const p of tc.tool.parameters) { md += `- ${p.name}: ${p.value}\n`; } md += "\n"; } if (tc.response) { md += `**Response:** ${tc.response}\n\n`; } if (tc.fileOperation) { const fo = tc.fileOperation; md += `**File Operation:** ${fo.type}\n`; if (fo.path) md += `**Path:** ${fo.path}\n`; if (fo.linesAdded !== undefined) md += `**Lines Added:** ${fo.linesAdded}\n`; if (fo.linesRemoved !== undefined) md += `**Lines Removed:** ${fo.linesRemoved}\n`; md += "\n"; } } md += "---\n\n"; } if (turn.fileOperations && turn.fileOperations.length > 0) { md += `### File Operations\n\n`; for (const fo of turn.fileOperations) { md += `#### File Operation: ${fo.type}\n\n`; if (fo.path) md += `**Path:** ${fo.path}\n`; if (fo.linesAdded !== undefined) md += `**Lines Added:** ${fo.linesAdded}\n`; if (fo.linesRemoved !== undefined) md += `**Lines Removed:** ${fo.linesRemoved}\n`; md += "\n---\n\n"; } } if (turn.subagentHistory && turn.subagentHistory.length > 0) { md += `### Subagent History\n\n`; for (const sh of turn.subagentHistory) { md += `#### Subagent Turn\n\n`; md += `**Timestamp:** ${sh.createdAt.toISOString()}\n`; md += `**Mode:** ${sh.mode}\n`; md += `**User:** ${sh.prompt}\n\n`; if (sh.response?.message) { md += `**Assistant:** ${sh.response.message}\n\n`; } if (sh.response?.thinking) { md += `**Thinking:** ${sh.response.thinking}\n\n`; } md += "---\n\n"; } } if (turn.error) { md += `### Error\n\n`; md += `**Message:** ${turn.error.message}\n\n`; if (turn.error.stack) { md += `**Stack:**\n\`\`\`\n${turn.error.stack}\n\`\`\`\n\n`; } md += "---\n\n"; } md += `---\n\n`; } return md; } private formatAsText( session: { _id: unknown; name: string; createdAt: Date; type: string }, history: IChatHistoryExport[], ): string { let txt = `${session.name}\n`; txt += `${"=".repeat(session.name.length)}\n\n`; txt += `Session ID: ${(session._id as any).toString()}\n`; txt += `Type: ${session.type}\n`; txt += `Created: ${session.createdAt.toISOString()}\n`; txt += `Turns: ${history.length}\n\n`; txt += `${"-".repeat(40)}\n\n`; for (const [i, turn] of history.entries()) { txt += `[Turn ${i + 1}] ${turn.createdAt.toISOString()}\n`; txt += `Mode: ${turn.mode}\n`; txt += `Status: ${turn.status}\n`; txt += `Subagent: ${turn.isSubagent ? "Yes" : "No"}\n\n`; txt += `USER:\n${turn.prompt}\n\n`; if (turn.response?.thinking) { txt += `THINKING:\n${turn.response.thinking}\n\n`; } if (turn.response?.message) { txt += `ASSISTANT:\n${turn.response.message}\n\n`; } if (turn.toolCalls && turn.toolCalls.length > 0) { txt += `TOOL CALLS:\n`; for (const tc of turn.toolCalls) { txt += `- Tool: ${tc.tool?.name || "unknown"} (Call ID: ${tc.tool?.callId || ""})\n`; if (tc.tool?.parameters) { for (const p of tc.tool.parameters) { txt += ` - ${p.name}: ${p.value}\n`; } } if (tc.response) { txt += ` Response: ${tc.response}\n`; } if (tc.fileOperation) { txt += ` File Op: ${tc.fileOperation.type}`; if (tc.fileOperation.path) txt += ` (${tc.fileOperation.path})`; txt += "\n"; } } txt += "\n"; } if (turn.fileOperations && turn.fileOperations.length > 0) { txt += `FILE OPERATIONS:\n`; for (const fo of turn.fileOperations) { txt += `- Op: ${fo.type}`; if (fo.path) txt += ` (${fo.path})`; txt += "\n"; } txt += "\n"; } if (turn.subagentHistory && turn.subagentHistory.length > 0) { txt += `SUBAGENT HISTORY:\n`; for (const sh of turn.subagentHistory) { txt += `- Turn [${sh.createdAt.toISOString()}]: ${sh.prompt.substring(0, 100)}...\n`; } txt += "\n"; } if (turn.error) { txt += `ERROR: ${turn.error.message}\n`; if (turn.error.stack) { txt += `Stack:\n${turn.error.stack}\n`; } txt += "\n"; } txt += `${"-".repeat(40)}\n\n`; } return txt; } } export default new ChatExportTool();