diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index d07d837..a4aea64 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -28,6 +28,7 @@ import { type ServerToClientEvents, type ClientToServerEvents, ChatSessionMode, + ChatTurnStatus, type IProject, } from "@gadget/api"; @@ -37,6 +38,7 @@ import WorkspaceService from "./workspace.ts"; import { GadgetService } from "../lib/service.ts"; import { AiToolbox, + ExportTool, FileEditTool, FileReadTool, FileWriteTool, @@ -56,6 +58,7 @@ import { ReadSkillTool, type DroneToolboxEnvironment, } from "../tools/index.ts"; +import type { IChatExportData } from "@gadget/ai-toolbox"; export interface IAgentWorkOrder { createdAt: Date; @@ -133,6 +136,11 @@ class AgentService extends GadgetService { subagentTool.setSpawner((agentType: string, prompt: string) => this.spawnSubagent(agentType, prompt)); this.toolbox.register(subagentTool, readOnlyModes); + // Chat tools — export: available in all modes + const exportTool = new ExportTool(this.toolbox); + exportTool.setSessionDataProvider(() => this.getExportData()); + this.toolbox.register(exportTool, readOnlyModes); + // Project tools — skill discovery: available in all modes this.toolbox.register(new ListSkillsTool(this.toolbox), readOnlyModes); this.toolbox.register(new ReadSkillTool(this.toolbox), readOnlyModes); @@ -540,6 +548,64 @@ class AgentService extends GadgetService { return modelRecord?.settings; } + /** + * Assembles all data needed for a chat session export, applying security + * scrubbing to remove PII, credentials, and infrastructure details. + * Only includes turns with finished/aborted/error status (excludes processing). + */ + private async getExportData(): Promise { + const workOrder = this.currentWorkOrder; + if (!workOrder) { + throw new Error("No active work order — cannot export"); + } + + const session = workOrder.turn.session as IChatSession; + if (!session) { + throw new Error("Chat session is not available on the current turn"); + } + + const user = session.user as IUser; + const provider = session.provider as IAiProvider; + const project = workOrder.turn.project as IProject; + + // Combine context turns (already completed) with current turn if finished + const allTurns = [...workOrder.context]; + if (workOrder.turn.status !== ChatTurnStatus.Processing) { + allTurns.push(workOrder.turn); + } + + // Filter to only finished/aborted/error turns + const exportableTurns = allTurns.filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + + // Build the model config (excluding the provider reference — we send it separately scrubbed) + const reasoning = workOrder.turn.reasoningEffort === "off" + ? false + : workOrder.turn.reasoningEffort || false; + const modelConfig = this.buildDroneModelConfig( + provider, + session.selectedModel, + reasoning as boolean | "low" | "medium" | "high", + session.numCtx, + ); + + return { + session, + turns: exportableTurns, + user, + provider, + project: { + name: project.name, + slug: project.slug, + description: project.description, + }, + modelConfig, + gadgetCodeVersion: env.pkg.version, + exportedAt: new Date(), + }; + } + private async executeTool(name: string, argsJson: string): Promise { const tool = this.toolbox.getTool(name); if (!tool) { diff --git a/gadget-drone/src/tools/index.ts b/gadget-drone/src/tools/index.ts index 8fbd15f..c452fa2 100644 --- a/gadget-drone/src/tools/index.ts +++ b/gadget-drone/src/tools/index.ts @@ -18,6 +18,7 @@ export { PlanFileWriteTool, PlanFileEditTool, PlanListTool, + ExportTool, SubagentTool, ListSkillsTool, ReadSkillTool, diff --git a/packages/ai-toolbox/src/chat/export.ts b/packages/ai-toolbox/src/chat/export.ts new file mode 100644 index 0000000..7e72f09 --- /dev/null +++ b/packages/ai-toolbox/src/chat/export.ts @@ -0,0 +1,728 @@ +// src/chat/export.ts +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; +import { formatError } from "@gadget/ai"; +import type { + IChatSession, + IChatTurn, + IChatTurnBlock, + IChatTurnBlockThinking, + IChatTurnBlockResponding, + IChatTurnBlockTool, + IChatToolCall, + IChatSubagentProcess, + IChatTurnStats, + IAiProvider, + IDroneModelConfig, + IUser, +} from "@gadget/api"; +import { ChatTurnStatus } from "@gadget/api"; + +import { GadgetTool } from "../tool.ts"; + +const VALID_FORMATS = ["markdown", "json"] as const; +type ExportFormat = (typeof VALID_FORMATS)[number]; + +/** + * Safe user representation — PII stripped. + * Only _id and displayName are included. The persona is included because + * it is user-authored and non-sensitive. + */ +export interface ISafeUser { + _id: string; + displayName: string; + persona?: string; +} + +/** + * Safe AI provider representation — secrets and infrastructure stripped. + * baseUrl is excluded (internal network info). apiKey is excluded (secret). + */ +export interface ISafeAiProvider { + _id: string; + name: string; + apiType: string; + enabled: boolean; +} + +/** + * Safe project representation — only non-sensitive metadata. + * gitUrl is excluded (could contain auth tokens in practice). + * system is excluded (may contain infrastructure details). + */ +export interface ISafeProject { + _id: string; + name: string; + slug: string; + description?: string; +} + +/** + * A single turn scrubbed for export. `prompts.system` is always excluded. + */ +export interface IExportTurn { + _id: string; + createdAt: string; + mode: string; + status: string; + prompts: { + user: string; + }; + blocks: IChatTurnBlock[]; + toolCalls: IChatToolCall[]; + subagents: IChatSubagentProcess[]; + stats: IChatTurnStats; + errorMessage?: string; +} + +/** + * The full export payload delivered by the AgentService data provider. + */ +export interface IChatExportData { + session: IChatSession; + turns: IChatTurn[]; + user: IUser; + provider: IAiProvider; + project: { name: string; slug: string; description?: string }; + modelConfig: Omit; + gadgetCodeVersion: string; + exportedAt: Date; +} + +// ── Utility helpers ──────────────────────────────────────────────────── + +function sanitizeSessionName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 50) || "session"; +} + +function formatTimestamp(date: Date): string { + const d = date instanceof Date ? date : new Date(date); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function toISO(date: Date | string): string { + return new Date(date).toISOString(); +} + +/** Strip PII, credentials, infrastructure info from a User record. */ +function safeUser(user: IUser): ISafeUser { + return { + _id: String(user._id), + displayName: user.displayName, + persona: user.persona, + }; +} + +/** Strip secrets and infrastructure info from an AiProvider record. */ +function safeProvider(provider: IAiProvider): ISafeAiProvider { + return { + _id: String(provider._id), + name: provider.name, + apiType: provider.apiType, + enabled: provider.enabled, + }; +} + +/** Strip sensitive info from a project record. */ +function safeProject(project: { name: string; slug: string; description?: string; gitUrl?: string; system?: string }): ISafeProject { + return { + _id: String((project as any)._id), + name: project.name, + slug: project.slug, + description: project.description, + }; +} + +/** Strip `prompts.system` from a turn and normalize dates. */ +function scrubTurn(turn: IChatTurn): IExportTurn { + return { + _id: String(turn._id), + createdAt: toISO(turn.createdAt), + mode: turn.mode, + status: turn.status, + prompts: { + user: turn.prompts.user, + }, + blocks: turn.blocks || [], + toolCalls: (turn.toolCalls || []).map(scrubToolCall), + subagents: (turn.subagents || []).map(scrubSubagent), + stats: turn.stats, + errorMessage: turn.errorMessage, + }; +} + +/** Scrub a tool call for export — no changes needed to the core structure, + * but we strip any subagent-embedded provider/user references. */ +function scrubToolCall(tc: IChatToolCall): IChatToolCall { + const result: IChatToolCall = { + callId: tc.callId, + name: tc.name, + parameters: tc.parameters, + response: tc.response, + }; + if (tc.subagent) { + result.subagent = scrubSubagent(tc.subagent); + } + return result; +} + +/** Scrub a subagent process for export. */ +function scrubSubagent(sa: IChatSubagentProcess): IChatSubagentProcess { + return { + prompt: sa.prompt, + thinking: sa.thinking, + response: sa.response, + toolCalls: (sa.toolCalls || []).map(scrubToolCall), + stats: sa.stats, + }; +} + +// ── Markdown formatter ───────────────────────────────────────────────── + +function formatAsMarkdown(data: IChatExportData): string { + const { session, turns, user, provider, project, modelConfig, gadgetCodeVersion } = data; + const safeUser_ = safeUser(user); + const safeProvider_ = safeProvider(provider); + + const finishedTurns = turns.filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + const totalInputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.inputTokens || 0), 0); + const totalOutputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.responseTokens || 0), 0); + const totalThinkingTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.thinkingTokenCount || 0), 0); + + const lines: string[] = []; + + // ── Header ── + lines.push(`# ${session.name}`); + lines.push(""); + lines.push(`**Session ID:** ${session._id}`); + lines.push(`**Created:** ${toISO(session.createdAt)}`); + if (session.lastMessageAt) { + lines.push(`**Last Message:** ${toISO(session.lastMessageAt)}`); + } + lines.push(`**Mode:** ${session.mode}`); + lines.push(`**Project:** ${project.name} (${project.slug})`); + lines.push(`**AI Provider:** ${safeProvider_.name} (${safeProvider_.apiType})`); + lines.push(`**Model:** ${session.selectedModel}`); + lines.push(`**Reasoning Effort:** ${session.reasoningEffort || "off"}`); + lines.push(`**Context Window:** ${session.numCtx || modelConfig.params.numCtx}`); + lines.push(`**Exported by:** Gadget Code v${gadgetCodeVersion}`); + lines.push(`**Exported at:** ${data.exportedAt.toISOString()}`); + lines.push(""); + lines.push(`**Turns:** ${finishedTurns.length}`); + lines.push(`**Tool Calls:** ${session.stats?.toolCallCount || 0}`); + lines.push(`**Total Input Tokens:** ${totalInputTokens.toLocaleString()}`); + lines.push(`**Total Output Tokens:** ${totalOutputTokens.toLocaleString()}`); + if (totalThinkingTokens > 0) { + lines.push(`**Total Thinking Tokens:** ${totalThinkingTokens.toLocaleString()}`); + } + lines.push(""); + + // ── Pins ── + if (session.pins && session.pins.length > 0) { + lines.push("## Pinned Context"); + lines.push(""); + for (const pin of session.pins) { + lines.push(`- ${pin.content}`); + } + lines.push(""); + } + + lines.push("---"); + lines.push(""); + + // ── Turns ── + for (const [i, turn] of finishedTurns.entries()) { + const scrubbed = scrubTurn(turn); + lines.push(`## Turn ${i + 1}`); + lines.push(""); + lines.push(`**Timestamp:** ${scrubbed.createdAt}`); + lines.push(`**Mode:** ${scrubbed.mode}`); + lines.push(`**Status:** ${scrubbed.status}`); + if (scrubbed.stats?.durationLabel) { + lines.push(`**Duration:** ${scrubbed.stats.durationLabel}`); + } + lines.push(""); + + // User prompt + lines.push("### User"); + lines.push(""); + lines.push(scrubbed.prompts.user); + lines.push(""); + + // Thinking blocks + const thinkingBlocks = scrubbed.blocks.filter( + (b) => b.mode === "thinking", + ) as IChatTurnBlockThinking[]; + if (thinkingBlocks.length > 0) { + lines.push("### Thinking"); + lines.push(""); + for (const block of thinkingBlocks) { + lines.push(block.content); + lines.push(""); + } + } + + // Response blocks + const responseBlocks = scrubbed.blocks.filter( + (b) => b.mode === "responding", + ) as IChatTurnBlockResponding[]; + if (responseBlocks.length > 0) { + lines.push("### Assistant"); + lines.push(""); + for (const block of responseBlocks) { + lines.push(block.content); + lines.push(""); + } + } + + // Tool call blocks + const toolBlocks = scrubbed.blocks.filter( + (b) => b.mode === "tool", + ) as IChatTurnBlockTool[]; + if (toolBlocks.length > 0 || scrubbed.toolCalls.length > 0) { + lines.push("### Tool Calls"); + lines.push(""); + + // From blocks + for (const block of toolBlocks) { + const tc = block.content; + lines.push(`#### ${tc.name}`); + lines.push(""); + lines.push("**Call ID:** " + tc.callId); + lines.push(""); + lines.push("**Parameters:**"); + lines.push("```json"); + try { + lines.push(JSON.stringify(JSON.parse(tc.parameters), null, 2)); + } catch { + lines.push(tc.parameters); + } + lines.push("```"); + lines.push(""); + if (tc.response) { + lines.push("**Response:**"); + lines.push("```"); + // Truncate very long responses for readability + const resp = tc.response.length > 5000 + ? tc.response.slice(0, 5000) + "\n... (truncated for readability)" + : tc.response; + lines.push(resp); + lines.push("```"); + lines.push(""); + } + if (tc.subagent) { + lines.push(formatSubagentMarkdown(tc.subagent)); + } + } + + // From toolCalls array (if not already covered by blocks) + const blockCallIds = new Set(toolBlocks.map((b) => b.content.callId)); + for (const tc of scrubbed.toolCalls) { + if (blockCallIds.has(tc.callId)) continue; + lines.push(`#### ${tc.name}`); + lines.push(""); + lines.push("**Call ID:** " + tc.callId); + lines.push(""); + lines.push("**Parameters:**"); + lines.push("```json"); + try { + lines.push(JSON.stringify(JSON.parse(tc.parameters), null, 2)); + } catch { + lines.push(tc.parameters); + } + lines.push("```"); + lines.push(""); + if (tc.response) { + lines.push("**Response:**"); + lines.push("```"); + const resp = tc.response.length > 5000 + ? tc.response.slice(0, 5000) + "\n... (truncated for readability)" + : tc.response; + lines.push(resp); + lines.push("```"); + lines.push(""); + } + if (tc.subagent) { + lines.push(formatSubagentMarkdown(tc.subagent)); + } + } + } + + // Subagents + if (scrubbed.subagents.length > 0) { + lines.push("### Subagents"); + lines.push(""); + for (const sa of scrubbed.subagents) { + lines.push(formatSubagentMarkdown(sa)); + } + } + + // Error + if (scrubbed.errorMessage) { + lines.push("### Error"); + lines.push(""); + lines.push(`> ${scrubbed.errorMessage}`); + lines.push(""); + } + + // Turn stats + lines.push(`*Token usage: ${scrubbed.stats?.inputTokens || 0} input / ${scrubbed.stats?.responseTokens || 0} output${scrubbed.stats?.thinkingTokenCount ? ` / ${scrubbed.stats.thinkingTokenCount} thinking` : ""}*`); + lines.push(""); + lines.push("---"); + lines.push(""); + } + + return lines.join("\n"); +} + +function formatSubagentMarkdown(sa: IChatSubagentProcess): string { + const lines: string[] = []; + lines.push(`##### Subagent`); + lines.push(""); + lines.push("**Prompt:** " + sa.prompt.slice(0, 200) + (sa.prompt.length > 200 ? "..." : "")); + lines.push(""); + if (sa.thinking) { + lines.push("**Thinking:**"); + lines.push("> " + sa.thinking.slice(0, 1000).replace(/\n/g, "\n> ")); + lines.push(""); + } + if (sa.response) { + lines.push("**Response:**"); + lines.push(sa.response.slice(0, 2000) + (sa.response.length > 2000 ? "\n... (truncated)" : "")); + lines.push(""); + } + if (sa.stats) { + lines.push(`*Subagent tokens: ${sa.stats.inputTokens || 0} input / ${sa.stats.responseTokens || 0} output / ${sa.stats.thinkingTokenCount || 0} thinking — ${sa.stats.durationLabel || "unknown"}*`); + lines.push(""); + } + return lines.join("\n"); +} + +// ── JSON formatter ───────────────────────────────────────────────────── + +function formatAsJson(data: IChatExportData): string { + const { session, turns, user, provider, project, modelConfig, gadgetCodeVersion } = data; + + const finishedTurns = turns.filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + const totalInputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.inputTokens || 0), 0); + const totalOutputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.responseTokens || 0), 0); + + const exportObj = { + exportMetadata: { + format: "gadget-code-chat-export-v1", + version: "1.0.0", + exportedAt: data.exportedAt.toISOString(), + gadgetCodeVersion, + gadgetCodeUrl: "https://g4dge7.com/", + }, + session: { + _id: String(session._id), + name: session.name, + mode: session.mode, + createdAt: toISO(session.createdAt), + lastMessageAt: session.lastMessageAt ? toISO(session.lastMessageAt) : undefined, + selectedModel: session.selectedModel, + reasoningEffort: session.reasoningEffort || "off", + numCtx: session.numCtx || modelConfig.params.numCtx, + stats: session.stats, + pins: session.pins || [], + }, + user: safeUser(user), + provider: safeProvider(provider), + project: safeProject(project), + modelConfig: { + modelId: modelConfig.modelId, + params: modelConfig.params, + }, + turns: finishedTurns.map(scrubTurn), + aggregateStats: { + turnCount: finishedTurns.length, + totalInputTokens, + totalOutputTokens, + }, + }; + + return JSON.stringify(exportObj, null, 2); +} + +// ── Companion Markdown (for JSON exports) ─────────────────────────────── + +function formatCompanionMarkdown(data: IChatExportData): string { + const { session, user, provider, modelConfig, gadgetCodeVersion } = data; + const safeUser_ = safeUser(user); + const safeProvider_ = safeProvider(provider); + + const finishedTurns = (data.turns || []).filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + const totalInputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.inputTokens || 0), 0); + const totalOutputTokens = finishedTurns.reduce((sum, t) => sum + (t.stats?.responseTokens || 0), 0); + + const lines: string[] = []; + + lines.push("# Chat Export — Companion Document"); + lines.push(""); + lines.push("## Export Details"); + lines.push(""); + lines.push(`- **Format:** Gadget Code Chat Export v1 (JSON)`); + lines.push(`- **Exported:** ${data.exportedAt.toISOString()}`); + lines.push(`- **Gadget Code Version:** ${gadgetCodeVersion}`); + lines.push(`- **Get Gadget Code:** https://g4dge7.com/`); + lines.push(""); + + lines.push("## Authors"); + lines.push(""); + lines.push("### Human Author"); + lines.push(`- **Name:** ${safeUser_.displayName}`); + lines.push(""); + + lines.push("### AI Agent"); + lines.push(`- **Agent:** Gadget Code version ${gadgetCodeVersion}`); + lines.push(`- **Provider:** ${safeProvider_.name} (${safeProvider_.apiType})`); + lines.push(`- **Model:** ${modelConfig.modelId}`); + lines.push(`- **Parameters:**`); + lines.push(` - Temperature: ${modelConfig.params.temperature}`); + lines.push(` - Top P: ${modelConfig.params.topP}`); + lines.push(` - Top K: ${modelConfig.params.topK}`); + lines.push(` - Context Window: ${modelConfig.params.numCtx}`); + lines.push(` - Max Completion Tokens: ${modelConfig.params.maxCompletionTokens}`); + lines.push(` - Num Predict: ${modelConfig.params.numPredict}`); + lines.push(` - Reasoning: ${modelConfig.params.reasoning}`); + lines.push(""); + + lines.push("## Session Summary"); + lines.push(""); + lines.push(`- **Session:** ${session.name}`); + lines.push(`- **Session ID:** ${session._id}`); + lines.push(`- **Mode:** ${session.mode}`); + lines.push(`- **Turns:** ${finishedTurns.length}`); + lines.push(`- **Tool Calls:** ${session.stats?.toolCallCount || 0}`); + lines.push(`- **Total Input Tokens:** ${totalInputTokens.toLocaleString()}`); + lines.push(`- **Total Output Tokens:** ${totalOutputTokens.toLocaleString()}`); + lines.push(""); + + lines.push("## About This Export"); + lines.push(""); + lines.push("This Markdown document accompanies a JSON export of a Gadget Code chat session."); + lines.push("The JSON file contains the complete machine-readable conversation data, suitable"); + lines.push("for import into other systems, analysis, or archival purposes."); + lines.push(""); + lines.push("The JSON export format is designed to be self-contained and interoperable. It"); + lines.push("includes all conversation turns, tool calls, subagent interactions, and session"); + lines.push("metadata needed to reconstruct the full conversation in another system."); + lines.push(""); + lines.push("For more information about Gadget Code, visit https://g4dge7.com/"); + + return lines.join("\n"); +} + +// ── Tool implementation ─────────────────────────────────────────────── + +export class ExportTool extends GadgetTool { + private _getSessionData: (() => Promise) | null = null; + + setSessionDataProvider(fn: () => Promise): void { + this._getSessionData = fn; + } + + get name(): string { + return "chat_export"; + } + + get category(): string { + return "chat"; + } + + get definition(): IToolDefinition { + return { + type: "function", + function: { + name: this.name, + description: + "Export the current chat session and all its conversation turns to disk as a document. " + + "The default format is Markdown, which produces a well-formatted conversation log including " + + "tool calls, responses, thinking blocks, and related metadata. " + + "The JSON format produces a complete machine-readable export suitable for import into " + + "other systems, analysis, or archival — it is always accompanied by a companion Markdown " + + "document that describes and credits the export. " + + "All exports exclude sensitive information (emails, API keys, hostnames, system prompts).", + parameters: { + type: "object", + properties: { + format: { + type: "string", + enum: [...VALID_FORMATS], + description: + "Export format: 'markdown' for a human-readable conversation log, " + + "'json' for a complete machine-readable export with a companion Markdown document. " + + "Defaults to 'markdown'.", + }, + }, + required: ["format"], + }, + }, + }; + } + + async execute(args: IToolArguments, logger: IAiLogger): Promise { + const { format } = args; + + // ── Validate format parameter ── + if (!format) { + return formatError({ + code: "MISSING_PARAMETER", + message: "The 'format' parameter is required.", + parameter: "format", + expected: "Either 'markdown' or 'json'", + recoveryHint: "Specify 'markdown' for a readable log or 'json' for a machine-readable export.", + }); + } + + if (!VALID_FORMATS.includes(format as ExportFormat)) { + return formatError({ + code: "INVALID_PARAMETER", + message: `Invalid format: '${format}'. Must be one of: ${VALID_FORMATS.join(", ")}`, + parameter: "format", + expected: `One of: ${VALID_FORMATS.join(", ")}`, + recoveryHint: "Use 'markdown' for a human-readable conversation log or 'json' for a complete data export.", + }); + } + + // ── Check session data provider ── + if (!this._getSessionData) { + return formatError({ + code: "OPERATION_FAILED", + message: "ExportTool has not been initialized with a session data provider.", + recoveryHint: "This is an internal initialization error. The agent service must wire the data provider during startup.", + }); + } + + // ── Get export data ── + let data: IChatExportData; + try { + data = await this._getSessionData(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to get export data", { error: message }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to retrieve session data for export: ${message}`, + recoveryHint: "Ensure the chat session is active and try again.", + }); + } + + // ── Determine file paths ── + const projectDir = this.toolbox.env.workspace?.projectDir; + if (!projectDir) { + return formatError({ + code: "OPERATION_FAILED", + message: "Workspace project directory is not available. The workspace must be initialized before exporting.", + recoveryHint: "Ensure the agent is running within an active workspace.", + }); + } + + const exportDir = path.join(projectDir, ".gadget", "exports"); + const baseName = sanitizeSessionName(data.session.name); + const timestamp = formatTimestamp(data.exportedAt); + const exportFormat = format as ExportFormat; + + logger.debug("exporting chat session", { + sessionId: String(data.session._id), + format: exportFormat, + turnCount: data.turns.length, + }); + + try { + // Ensure export directory exists + await fs.mkdir(exportDir, { recursive: true }); + + const results: string[] = []; + + if (exportFormat === "markdown") { + // ── Markdown export ── + const content = formatAsMarkdown(data); + const fileName = `${baseName}-${timestamp}.md`; + const filePath = path.join(exportDir, fileName); + await fs.writeFile(filePath, content, "utf-8"); + const byteCount = Buffer.byteLength(content, "utf-8"); + results.push(`.gadget/exports/${fileName}`); + + // Build success response + const finishedTurns = data.turns.filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + const totalInput = finishedTurns.reduce((s, t) => s + (t.stats?.inputTokens || 0), 0); + const totalOutput = finishedTurns.reduce((s, t) => s + (t.stats?.responseTokens || 0), 0); + + return [ + "CHAT EXPORT SUCCESS", + `Format: markdown`, + `Session: ${data.session.name}`, + `Turns: ${finishedTurns.length}`, + `Total Input Tokens: ${totalInput.toLocaleString()}`, + `Total Output Tokens: ${totalOutput.toLocaleString()}`, + `Bytes: ${byteCount.toLocaleString()}`, + `Files:`, + ...results.map((r) => ` - ${r}`), + ].join("\n"); + } + + // ── JSON export (always with companion markdown) ── + const jsonContent = formatAsJson(data); + const jsonFileName = `${baseName}-${timestamp}.json`; + const jsonFilePath = path.join(exportDir, jsonFileName); + await fs.writeFile(jsonFilePath, jsonContent, "utf-8"); + const jsonBytes = Buffer.byteLength(jsonContent, "utf-8"); + results.push(`.gadget/exports/${jsonFileName}`); + + const companionContent = formatCompanionMarkdown(data); + const companionFileName = `${baseName}-${timestamp}-readme.md`; + const companionFilePath = path.join(exportDir, companionFileName); + await fs.writeFile(companionFilePath, companionContent, "utf-8"); + const companionBytes = Buffer.byteLength(companionContent, "utf-8"); + results.push(`.gadget/exports/${companionFileName}`); + + // Build success response + const finishedTurns = data.turns.filter( + (t) => t.status !== ChatTurnStatus.Processing, + ); + const totalInput = finishedTurns.reduce((s, t) => s + (t.stats?.inputTokens || 0), 0); + const totalOutput = finishedTurns.reduce((s, t) => s + (t.stats?.responseTokens || 0), 0); + + return [ + "CHAT EXPORT SUCCESS", + `Format: json + companion markdown`, + `Session: ${data.session.name}`, + `Turns: ${finishedTurns.length}`, + `Total Input Tokens: ${totalInput.toLocaleString()}`, + `Total Output Tokens: ${totalOutput.toLocaleString()}`, + `JSON Bytes: ${jsonBytes.toLocaleString()}`, + `Readme Bytes: ${companionBytes.toLocaleString()}`, + `Files:`, + ...results.map((r) => ` - ${r}`), + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("failed to write export file", { error: message }); + return formatError({ + code: "OPERATION_FAILED", + message: `Failed to write export file: ${message}`, + recoveryHint: "Check filesystem permissions and available disk space, then try again.", + }); + } + } +} diff --git a/packages/ai-toolbox/src/chat/index.ts b/packages/ai-toolbox/src/chat/index.ts index 171440f..95873ae 100644 --- a/packages/ai-toolbox/src/chat/index.ts +++ b/packages/ai-toolbox/src/chat/index.ts @@ -3,3 +3,4 @@ // Licensed under the Apache License, Version 2.0 export { SubagentTool } from "./subagent.ts"; +export { ExportTool, type IChatExportData, type ISafeUser, type ISafeAiProvider, type ISafeProject, type IExportTurn } from "./export.ts";