554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
// 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<string> {
|
|
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<IChatHistoryExport[]> {
|
|
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();
|