Compare commits
No commits in common. "9c1e65785df5c443dbdec4d52faca32893bb2de5" and "c2028f45c53ed8017bdbbbcad2c2fbd1aa4c607b" have entirely different histories.
9c1e65785d
...
c2028f45c5
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -18,7 +18,6 @@ export {
|
||||
PlanFileWriteTool,
|
||||
PlanFileEditTool,
|
||||
PlanListTool,
|
||||
ExportTool,
|
||||
SubagentTool,
|
||||
ListSkillsTool,
|
||||
ReadSkillTool,
|
||||
|
||||
@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user