gadget/docs/archive/tools/chat/export.ts

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();