Skip to content

Commit bccc3b0

Browse files
committed
feat: add session export cli command
opencode session export [sessionID] export session transcript to file Positionals: sessionID session id to export Options: -o, --output output file path -f, --format output format [string] [choices: "markdown", "json"] [default: "markdown"] Fixes: #5426 Signed-off-by: Christian Stewart <[email protected]>
1 parent 4299450 commit bccc3b0

File tree

2 files changed

+167
-2
lines changed

2 files changed

+167
-2
lines changed

packages/opencode/src/cli/cmd/session.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { UI } from "../ui"
66
import { Locale } from "../../util/locale"
77
import { Flag } from "../../flag/flag"
88
import { EOL } from "os"
9-
import path from "path"
9+
import * as prompts from "@clack/prompts"
1010

1111
function pagerCmd(): string[] {
1212
const lessOptions = ["-R", "-S"]
@@ -38,7 +38,7 @@ function pagerCmd(): string[] {
3838
export const SessionCommand = cmd({
3939
command: "session",
4040
describe: "manage sessions",
41-
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
41+
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionExportCommand).demandCommand(),
4242
async handler() {},
4343
})
4444

@@ -133,3 +133,119 @@ function formatSessionJSON(sessions: Session.Info[]): string {
133133
}))
134134
return JSON.stringify(jsonData, null, 2)
135135
}
136+
137+
export const SessionExportCommand = cmd({
138+
command: "export [sessionID]",
139+
describe: "export session transcript to file",
140+
builder: (yargs: Argv) => {
141+
return yargs
142+
.positional("sessionID", {
143+
describe: "session id to export",
144+
type: "string",
145+
})
146+
.option("output", {
147+
alias: "o",
148+
describe: "output file path",
149+
type: "string",
150+
})
151+
.option("format", {
152+
alias: "f",
153+
describe: "output format",
154+
type: "string",
155+
choices: ["markdown", "json"],
156+
default: "markdown",
157+
})
158+
},
159+
handler: async (args) => {
160+
await bootstrap(process.cwd(), async () => {
161+
let sessionID = args.sessionID
162+
163+
if (!sessionID) {
164+
prompts.intro("Export session", {
165+
output: process.stderr,
166+
})
167+
168+
const sessions = []
169+
for await (const session of Session.list()) {
170+
sessions.push(session)
171+
}
172+
173+
if (sessions.length === 0) {
174+
prompts.log.error("No sessions found", {
175+
output: process.stderr,
176+
})
177+
prompts.outro("Done", {
178+
output: process.stderr,
179+
})
180+
return
181+
}
182+
183+
sessions.sort((a, b) => b.time.updated - a.time.updated)
184+
185+
const selectedSession = await prompts.autocomplete({
186+
message: "Select session to export",
187+
maxItems: 10,
188+
options: sessions.map((session) => ({
189+
label: session.title,
190+
value: session.id,
191+
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
192+
})),
193+
output: process.stderr,
194+
})
195+
196+
if (prompts.isCancel(selectedSession)) {
197+
throw new UI.CancelledError()
198+
}
199+
200+
sessionID = selectedSession as string
201+
}
202+
203+
const sessionInfo = await Session.get(sessionID!)
204+
205+
let content: string
206+
let defaultExtension: string
207+
208+
if (args.format === "json") {
209+
const sessionMessages = await Session.messages({ sessionID: sessionID! })
210+
const exportData = {
211+
info: sessionInfo,
212+
messages: sessionMessages.map((msg) => ({
213+
info: msg.info,
214+
parts: msg.parts,
215+
})),
216+
}
217+
content = JSON.stringify(exportData, null, 2)
218+
defaultExtension = "json"
219+
} else {
220+
content = await Session.exportMarkdown({
221+
sessionID: sessionID!,
222+
includeThinking: true,
223+
includeToolDetails: true,
224+
})
225+
defaultExtension = "md"
226+
}
227+
228+
const outputPath = await (async () => {
229+
if (args.output) return args.output
230+
const defaultFilename = `session-${sessionInfo.id.slice(0, 8)}.${defaultExtension}`
231+
const filenameInput = await prompts.text({
232+
message: "Export filename",
233+
defaultValue: defaultFilename,
234+
output: process.stderr,
235+
})
236+
237+
if (prompts.isCancel(filenameInput)) {
238+
throw new UI.CancelledError()
239+
}
240+
241+
return filenameInput.trim()
242+
})()
243+
244+
await Bun.write(outputPath, content)
245+
246+
prompts.outro(`Session exported to ${outputPath}`, {
247+
output: process.stderr,
248+
})
249+
})
250+
},
251+
})

packages/opencode/src/session/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,55 @@ export namespace Session {
305305
},
306306
)
307307

308+
export const exportMarkdown = fn(
309+
z.object({
310+
sessionID: Identifier.schema("session"),
311+
includeThinking: z.boolean().optional().default(true),
312+
includeToolDetails: z.boolean().optional().default(true),
313+
}),
314+
async (input) => {
315+
const sessionInfo = await get(input.sessionID)
316+
const sessionMessages = await messages({ sessionID: input.sessionID })
317+
318+
let transcript = `# ${sessionInfo.title}\n\n`
319+
transcript += `**Session ID:** ${sessionInfo.id}\n`
320+
transcript += `**Created:** ${new Date(sessionInfo.time.created).toLocaleString()}\n`
321+
transcript += `**Updated:** ${new Date(sessionInfo.time.updated).toLocaleString()}\n\n`
322+
transcript += `---\n\n`
323+
324+
for (const msg of sessionMessages) {
325+
const role = msg.info.role === "user" ? "User" : "Assistant"
326+
transcript += `## ${role}\n\n`
327+
328+
for (const part of msg.parts) {
329+
if (part.type === "text" && !part.synthetic) {
330+
transcript += `${part.text}\n\n`
331+
} else if (part.type === "reasoning") {
332+
if (input.includeThinking) {
333+
transcript += `_Thinking:_\n\n${part.text}\n\n`
334+
}
335+
} else if (part.type === "tool") {
336+
transcript += `\`\`\`\nTool: ${part.tool}\n`
337+
if (input.includeToolDetails && part.state.input) {
338+
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
339+
}
340+
if (input.includeToolDetails && part.state.status === "completed" && part.state.output) {
341+
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
342+
}
343+
if (input.includeToolDetails && part.state.status === "error" && part.state.error) {
344+
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
345+
}
346+
transcript += `\n\`\`\`\n\n`
347+
}
348+
}
349+
350+
transcript += `---\n\n`
351+
}
352+
353+
return transcript
354+
},
355+
)
356+
308357
export async function* list() {
309358
const project = Instance.project
310359
for (const item of await Storage.list(["session", project.id])) {

0 commit comments

Comments
 (0)