feat: add chat_export tool for session export (markdown/json)
New chat tool: chat_export - Export current ChatSession and all finished ChatTurn records to disk - Two formats: markdown (human-readable) and json (machine-readable) - JSON exports always include a companion -readme.md with author credits, Gadget Code version/link, AI provider/model/parameters - Output to .gadget/exports/ with sanitized session name + timestamp - Security-first: excludes emails, API keys, baseUrls, system prompts, gitUrls, and other PII/infrastructure details from all exports - Only finished/aborted/error turns included (processing excluded) - Follows SubagentTool setter-injection pattern for session data - Available in all chat session modes (plan/build/test/ship/dev) Files: - packages/ai-toolbox/src/chat/export.ts (NEW - 729 lines) - packages/ai-toolbox/src/chat/index.ts (barrel update) - gadget-drone/src/tools/index.ts (re-export update) - gadget-drone/src/services/agent.ts (import, register, getExportData)
This commit is contained in:
parent
0437ca66d8
commit
9c1e65785d
@ -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<IChatExportData> {
|
||||
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<string> {
|
||||
const tool = this.toolbox.getTool(name);
|
||||
if (!tool) {
|
||||
|
||||
@ -18,6 +18,7 @@ export {
|
||||
PlanFileWriteTool,
|
||||
PlanFileEditTool,
|
||||
PlanListTool,
|
||||
ExportTool,
|
||||
SubagentTool,
|
||||
ListSkillsTool,
|
||||
ReadSkillTool,
|
||||
|
||||
728
packages/ai-toolbox/src/chat/export.ts
Normal file
728
packages/ai-toolbox/src/chat/export.ts
Normal file
@ -0,0 +1,728 @@
|
||||
// src/chat/export.ts
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// 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<IDroneModelConfig, "provider">;
|
||||
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<IChatExportData>) | null = null;
|
||||
|
||||
setSessionDataProvider(fn: () => Promise<IChatExportData>): 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<string> {
|
||||
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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user