feat(coding-agent): add JSONL export/import for sessions (#2356)
fixes #2274
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
* Modes use this class and add their own I/O layer on top.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import type {
|
||||
Agent,
|
||||
AgentEvent,
|
||||
@@ -73,7 +73,7 @@ import type { ModelRegistry } from "./model-registry.js";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js";
|
||||
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
|
||||
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
|
||||
import { getLatestCompactionEntry } from "./session-manager.js";
|
||||
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js";
|
||||
import { buildSystemPrompt } from "./system-prompt.js";
|
||||
@@ -3067,6 +3067,68 @@ export class AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current session branch to a JSONL file.
|
||||
* Writes the session header followed by all entries on the current branch path.
|
||||
* @param outputPath Target file path. If omitted, generates a timestamped file in cwd.
|
||||
* @returns The resolved output file path.
|
||||
*/
|
||||
exportToJsonl(outputPath?: string): string {
|
||||
const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: this.sessionManager.getSessionId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
};
|
||||
|
||||
const branchEntries = this.sessionManager.getBranch();
|
||||
const lines = [JSON.stringify(header)];
|
||||
|
||||
// Re-chain parentIds to form a linear sequence
|
||||
let prevId: string | null = null;
|
||||
for (const entry of branchEntries) {
|
||||
const linear = { ...entry, parentId: prevId };
|
||||
lines.push(JSON.stringify(linear));
|
||||
prevId = entry.id;
|
||||
}
|
||||
|
||||
writeFileSync(filePath, `${lines.join("\n")}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a JSONL session file.
|
||||
* Copies the file into the session directory and switches to it (like /resume).
|
||||
* @param inputPath Path to the JSONL file to import.
|
||||
* @returns true if the session was switched successfully.
|
||||
*/
|
||||
async importFromJsonl(inputPath: string): Promise<boolean> {
|
||||
const resolved = resolve(inputPath);
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`File not found: ${resolved}`);
|
||||
}
|
||||
|
||||
// Copy into the session directory so we don't modify the original
|
||||
const sessionDir = this.sessionManager.getSessionDir();
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
const destPath = join(sessionDir, basename(resolved));
|
||||
// Avoid overwriting if source and destination are the same file
|
||||
if (resolve(destPath) !== resolved) {
|
||||
copyFileSync(resolved, destPath);
|
||||
}
|
||||
|
||||
return this.switchSession(destPath);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Utilities
|
||||
// =========================================================================
|
||||
|
||||
@@ -19,7 +19,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
|
||||
{ name: "settings", description: "Open settings menu" },
|
||||
{ name: "model", description: "Select model (opens selector UI)" },
|
||||
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
||||
{ name: "export", description: "Export session to HTML file" },
|
||||
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
||||
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
||||
{ name: "share", description: "Share session as a secret GitHub gist" },
|
||||
{ name: "copy", description: "Copy last agent message to clipboard" },
|
||||
{ name: "name", description: "Set session display name" },
|
||||
|
||||
@@ -1977,6 +1977,11 @@ export class InteractiveMode {
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("/import")) {
|
||||
await this.handleImportCommand(text);
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/share") {
|
||||
await this.handleShareCommand();
|
||||
this.editor.setText("");
|
||||
@@ -3909,13 +3914,62 @@ export class InteractiveMode {
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
||||
try {
|
||||
const filePath = await this.session.exportToHtml(outputPath);
|
||||
this.showStatus(`Session exported to: ${filePath}`);
|
||||
if (outputPath?.endsWith(".jsonl")) {
|
||||
const filePath = this.session.exportToJsonl(outputPath);
|
||||
this.showStatus(`Session exported to: ${filePath}`);
|
||||
} else {
|
||||
const filePath = await this.session.exportToHtml(outputPath);
|
||||
this.showStatus(`Session exported to: ${filePath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleImportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
if (parts.length < 2 || !parts[1]) {
|
||||
this.showError("Usage: /import <path.jsonl>");
|
||||
return;
|
||||
}
|
||||
const inputPath = parts[1];
|
||||
|
||||
const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`);
|
||||
if (!confirmed) {
|
||||
this.showStatus("Import cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Clear UI state
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.compactionQueuedMessages = [];
|
||||
this.streamingComponent = undefined;
|
||||
this.streamingMessage = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
const success = await this.session.importFromJsonl(inputPath);
|
||||
if (!success) {
|
||||
this.showWarning("Import cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear and re-render the chat
|
||||
this.chatContainer.clear();
|
||||
this.renderInitialMessages();
|
||||
this.showStatus(`Session imported from: ${inputPath}`);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShareCommand(): Promise<void> {
|
||||
// Check if gh is available and logged in
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user