Compare commits

..

No commits in common. "9c1e65785df5c443dbdec4d52faca32893bb2de5" and "c2028f45c53ed8017bdbbbcad2c2fbd1aa4c607b" have entirely different histories.

6 changed files with 112 additions and 919 deletions

View File

@ -28,7 +28,7 @@ export default function Clock() {
}, []);
return (
<div className="p-3 shrink-0">
<div className="p-3">
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Clock
</div>

View File

@ -269,12 +269,53 @@ function DashboardSidebar({
};
return (
<aside className="w-64 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden min-h-0">
<aside className="w-64 border-l border-border-subtle bg-bg-secondary overflow-y-auto">
<Clock />
{/* Projects */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider">
<div className="p-3 border-t border-border-subtle">
<div className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Recent Chats
</div>
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : recentChats.length === 0 ? (
<p className="text-sm text-text-muted px-2">No recent chats.</p>
) : (
<div className="space-y-1">
{recentChats.map((session) => (
<button
key={session._id}
onClick={() => handleOpenChat(session)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-medium text-xs">{session.name || 'Untitled'}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${modeBadgeColors[session.mode] || 'bg-gray-500/20 text-gray-400'}`}>
{session.mode}
</span>
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-[11px] text-text-muted truncate">{getProjectName(session)}</span>
<span className="text-[10px] text-text-muted shrink-0">
{formatRelativeTime(session.lastMessageAt || session.createdAt)}
</span>
</div>
</button>
))}
</div>
)}
{noDroneMessage && (
<p className="text-xs text-yellow-400 mt-2 px-2">{noDroneMessage}</p>
)}
{selectedDrone && (
<p className="text-[10px] text-text-muted mt-2 px-2 italic">
Select a chat to resume with {selectedDrone.hostname}
</p>
)}
</div>
<div className="p-3 border-t border-border-subtle">
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
<span>Projects</span>
<Link
to="/projects"
@ -283,30 +324,27 @@ function DashboardSidebar({
[+]
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : projects.length === 0 ? (
<p className="text-sm text-text-muted px-2">No projects yet.</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project._id}
onClick={() => navigate(`/projects/${project.slug}`)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded truncate transition-colors"
>
{project.name}
</button>
))}
</div>
)}
</div>
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : projects.length === 0 ? (
<p className="text-sm text-text-muted px-2">No projects yet.</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project._id}
onClick={() => navigate(`/projects/${project.slug}`)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded truncate transition-colors"
>
{project.name}
</button>
))}
</div>
)}
</div>
{/* Drones */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider">
<div className="p-3 border-t border-border-subtle">
<div className="flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
<span>Drones</span>
<Link
to="/drones"
@ -334,100 +372,51 @@ function DashboardSidebar({
</svg>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : drones.length === 0 ? (
<p className="text-sm text-text-muted px-2">No drones available.</p>
) : (
<div className="space-y-1">
{drones.map((drone) => (
<button
key={drone._id}
onClick={() => onSelectDrone(drone)}
className={`w-full text-left px-2 py-1 rounded transition-colors ${
selectedDrone?._id === drone._id
? "bg-brand text-white"
: "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
}`}
>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full shrink-0 ${
drone.status === "available"
? "bg-green-500"
: "bg-yellow-500"
}`}
/>
<div className="overflow-hidden">
<div className="text-sm truncate">{drone.hostname}</div>
{drone.workspaceDir && (
<div
className={`text-xs truncate ${
selectedDrone?._id === drone._id
? "text-white/70"
: "text-text-muted"
}`}
>
{drone.workspaceDir.length > 24
? "..." + drone.workspaceDir.slice(-21)
: drone.workspaceDir}
</div>
)}
</div>
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : drones.length === 0 ? (
<p className="text-sm text-text-muted px-2">No drones available.</p>
) : (
<div className="space-y-1">
{drones.map((drone) => (
<button
key={drone._id}
onClick={() => onSelectDrone(drone)}
className={`w-full text-left px-2 py-1 rounded transition-colors ${
selectedDrone?._id === drone._id
? "bg-brand text-white"
: "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
}`}
>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full shrink-0 ${
drone.status === "available"
? "bg-green-500"
: "bg-yellow-500"
}`}
/>
<div className="overflow-hidden">
<div className="text-sm truncate">{drone.hostname}</div>
{drone.workspaceDir && (
<div
className={`text-xs truncate ${
selectedDrone?._id === drone._id
? "text-white/70"
: "text-text-muted"
}`}
>
{drone.workspaceDir.length > 24
? "..." + drone.workspaceDir.slice(-21)
: drone.workspaceDir}
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Recent Chats */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 text-xs font-semibold text-text-muted uppercase tracking-wider">
Recent Chats
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : recentChats.length === 0 ? (
<p className="text-sm text-text-muted px-2">No recent chats.</p>
) : (
<div className="space-y-1">
{recentChats.map((session) => (
<button
key={session._id}
onClick={() => handleOpenChat(session)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-medium text-xs">{session.name || 'Untitled'}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${modeBadgeColors[session.mode] || 'bg-gray-500/20 text-gray-400'}`}>
{session.mode}
</span>
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-[11px] text-text-muted truncate">{getProjectName(session)}</span>
<span className="text-[10px] text-text-muted shrink-0">
{formatRelativeTime(session.lastMessageAt || session.createdAt)}
</span>
</div>
</button>
))}
</div>
)}
</div>
<div className="shrink-0 px-3 pb-3">
{noDroneMessage && (
<p className="text-xs text-yellow-400 mt-2 px-2">{noDroneMessage}</p>
)}
{selectedDrone && (
<p className="text-[10px] text-text-muted mt-2 px-2 italic">
Select a chat to resume with {selectedDrone.hostname}
</p>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
</aside>
@ -458,7 +447,7 @@ export default function Home({ user }: HomeProps) {
}
const mainContent = selectedDrone ? (
<div className="relative z-10 flex-1 flex min-h-0">
<div className="relative z-10 flex-1 flex">
<DroneInspector
drone={selectedDrone}
onClose={() => setSelectedDrone(null)}
@ -474,8 +463,8 @@ export default function Home({ user }: HomeProps) {
/>
</div>
) : (
<div className="relative z-10 flex-1 flex flex-col min-h-0">
<div className="flex-1 flex min-h-0">
<div className="relative z-10 flex-1 flex flex-col">
<div className="flex-1 flex">
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold mb-4">

View File

@ -28,7 +28,6 @@ import {
type ServerToClientEvents,
type ClientToServerEvents,
ChatSessionMode,
ChatTurnStatus,
type IProject,
} from "@gadget/api";
@ -38,7 +37,6 @@ import WorkspaceService from "./workspace.ts";
import { GadgetService } from "../lib/service.ts";
import {
AiToolbox,
ExportTool,
FileEditTool,
FileReadTool,
FileWriteTool,
@ -58,7 +56,6 @@ import {
ReadSkillTool,
type DroneToolboxEnvironment,
} from "../tools/index.ts";
import type { IChatExportData } from "@gadget/ai-toolbox";
export interface IAgentWorkOrder {
createdAt: Date;
@ -136,11 +133,6 @@ 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);
@ -548,64 +540,6 @@ 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) {

View File

@ -18,7 +18,6 @@ export {
PlanFileWriteTool,
PlanFileEditTool,
PlanListTool,
ExportTool,
SubagentTool,
ListSkillsTool,
ReadSkillTool,

View File

@ -1,728 +0,0 @@
// 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.",
});
}
}
}

View File

@ -3,4 +3,3 @@
// 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";