Skip to content

Commit 13cf015

Browse files
committed
feat(sessions): export a conversation as Markdown from the sidebar
Closes the last open item of the local-studio plan. Export existed only as /export typed inside a live session: a runtime builtin that runs in a turn and writes into the project directory. A sidebar entry driving that would have to boot a session to produce a file in the user's repository, so this reads the stored transcript instead and hands back a download. GET /api/providers/sessions/:id/export renders the session as Markdown. It asks the provider directly rather than going through sessionsService, whose history replaces tool output over 64KB with a bounded preview - an export that dropped the middle of a build log would be worse than no export. Code blocks are fenced longer than anything they contain, so tool output carrying its own Markdown cannot break the document. The plain Content-Disposition filename is ASCII-only: Node rejects header values above latin1 with ERR_INVALID_CHAR, so a Korean session title would have turned its own download into a 500. The real name travels percent-encoded in filename*, which the client reads back. Verified against a real transcript on disk: full output preserved, the route returns the document rather than the JSON envelope, and the header survives a non-latin name.
1 parent 58eed4a commit 13cf015

23 files changed

Lines changed: 864 additions & 25 deletions

server/modules/providers/provider.routes.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { providerCommandsService } from '@/modules/providers/services/provider-c
66
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
77
import { providerSkillsService } from '@/modules/providers/services/provider-skills.service.js';
88
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
9+
import { exportSessionTranscript } from '@/modules/providers/services/session-export.service.js';
910
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
1011
import { getHomeDir, getHomeDirSuggestions } from '@/modules/providers/services/home-dirs.service.js';
1112
import type {
@@ -380,6 +381,27 @@ router.put(
380381
}),
381382
);
382383

384+
/*
385+
* Downloads one session as Markdown.
386+
*
387+
* Not wrapped in the standard success envelope: the response body is the file
388+
* itself, so the browser can save it directly.
389+
*/
390+
router.get(
391+
'/sessions/:sessionId/export',
392+
asyncHandler(async (req: Request, res: Response) => {
393+
const sessionId = parseSessionId(req.params.sessionId);
394+
const transcript = await exportSessionTranscript(sessionId);
395+
396+
res.setHeader('Content-Type', transcript.contentType);
397+
res.setHeader(
398+
'Content-Disposition',
399+
`attachment; filename="${transcript.asciiFilename}"; filename*=UTF-8''${encodeURIComponent(transcript.filename)}`,
400+
);
401+
res.send(transcript.body);
402+
}),
403+
);
404+
383405
router.get(
384406
'/sessions/:sessionId/messages',
385407
asyncHandler(async (req: Request, res: Response) => {
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import path from 'node:path';
2+
3+
import { sessionsDb } from '@/modules/database/index.js';
4+
import { providerRegistry } from '@/modules/providers/provider.registry.js';
5+
import type { LLMProvider, NormalizedMessage } from '@/shared/types.js';
6+
import { AppError } from '@/shared/utils.js';
7+
8+
export type SessionExport = {
9+
filename: string;
10+
/** Header-safe form of the same name, for the plain `filename` parameter. */
11+
asciiFilename: string;
12+
contentType: string;
13+
body: string;
14+
};
15+
16+
/**
17+
* Kinds that exist to drive the live UI rather than to record what was said:
18+
* stream fragments, status lines, permission prompts and run bookkeeping. They
19+
* are the transcript's plumbing, and a saved conversation is not improved by
20+
* replaying them.
21+
*/
22+
const TRANSIENT_KINDS = new Set([
23+
'stream_delta',
24+
'stream_end',
25+
'complete',
26+
'status',
27+
'permission_request',
28+
'permission_cancelled',
29+
'session_created',
30+
]);
31+
32+
/**
33+
* A fence long enough to survive whatever the content itself contains. Tool
34+
* output routinely holds Markdown, so a fixed three-backtick fence would let a
35+
* result close its own block and spill formatting over the rest of the file.
36+
*/
37+
function fenceFor(content: string): string {
38+
const longestRun = [...content.matchAll(/`+/g)]
39+
.reduce((longest, match) => Math.max(longest, match[0].length), 0);
40+
return '`'.repeat(Math.max(3, longestRun + 1));
41+
}
42+
43+
function codeBlock(content: string, language = ''): string {
44+
const body = content.replace(/\s+$/, '');
45+
const fence = fenceFor(body);
46+
return `${fence}${language}\n${body}\n${fence}`;
47+
}
48+
49+
function stringify(value: unknown): string {
50+
if (typeof value === 'string') return value;
51+
if (value === undefined || value === null) return '';
52+
try {
53+
return JSON.stringify(value, null, 2) ?? String(value);
54+
} catch {
55+
return String(value);
56+
}
57+
}
58+
59+
/**
60+
* A file name that is safe on every platform the app runs on and still says
61+
* which conversation it holds.
62+
*/
63+
export function exportFileName(title: string, sessionId: string, exportedAt: Date): string {
64+
const slug = title
65+
// Composed, not decomposed: NFKD splits Hangul into jamo, so a Korean title
66+
// came back as a string that looks right and compares unequal.
67+
.normalize('NFC')
68+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
69+
.replace(/^-+|-+$/g, '')
70+
.slice(0, 60)
71+
.toLowerCase();
72+
const day = exportedAt.toISOString().slice(0, 10);
73+
const stem = slug || sessionId.replace(/[^A-Za-z0-9]+/g, '-');
74+
return `${stem}-${day}.md`;
75+
}
76+
77+
/**
78+
* HTTP header values cannot carry anything above latin1 - Node rejects the
79+
* whole response with ERR_INVALID_CHAR - so a Korean session title would have
80+
* turned its own download into a 500. The real name still travels in
81+
* `filename*`; this is what the plain `filename` parameter falls back to.
82+
*/
83+
export function asciiFileName(filename: string, sessionId: string, exportedAt: Date): string {
84+
if (/^[\x20-\x7E]+$/.test(filename)) return filename;
85+
const day = exportedAt.toISOString().slice(0, 10);
86+
return `${sessionId.replace(/[^A-Za-z0-9]+/g, '-')}-${day}.md`;
87+
}
88+
89+
function renderToolCall(message: NormalizedMessage): string {
90+
const parts: string[] = [`### Tool: ${message.toolName || 'unknown'}`];
91+
if (message.timestamp) parts.push(`*${message.timestamp}*`);
92+
93+
const input = stringify(message.toolInput);
94+
if (input.trim()) parts.push(codeBlock(input, 'json'));
95+
96+
const result = message.toolResult;
97+
if (result && result.content !== undefined) {
98+
const output = stringify(result.content);
99+
if (output.trim()) {
100+
parts.push(result.isError ? '**Error**' : '**Output**');
101+
parts.push(codeBlock(output));
102+
}
103+
}
104+
105+
return parts.join('\n\n');
106+
}
107+
108+
function renderMessage(message: NormalizedMessage): string | null {
109+
if (TRANSIENT_KINDS.has(message.kind)) return null;
110+
111+
switch (message.kind) {
112+
case 'tool_use':
113+
return renderToolCall(message);
114+
115+
case 'tool_result': {
116+
const output = stringify(message.content);
117+
if (!output.trim()) return null;
118+
return [
119+
`### Tool result${message.toolId ? ` (${message.toolId})` : ''}`,
120+
message.isError ? '**Error**' : '**Output**',
121+
codeBlock(output),
122+
].join('\n\n');
123+
}
124+
125+
case 'thinking': {
126+
const content = stringify(message.content);
127+
if (!content.trim()) return null;
128+
return [`### Thinking`, `*${message.timestamp}*`, content].join('\n\n');
129+
}
130+
131+
case 'system_notice':
132+
return `> **${(message.level ?? 'info').toUpperCase()}** ${stringify(message.content).replace(/\n/g, '\n> ')}`;
133+
134+
case 'error':
135+
return ['### Error', codeBlock(stringify(message.content))].join('\n\n');
136+
137+
case 'task_notification':
138+
return `> ${stringify(message.content)}`;
139+
140+
default: {
141+
const content = stringify(message.content);
142+
const attachments = Array.isArray(message.images) ? message.images.length : 0;
143+
if (!content.trim() && attachments === 0) return null;
144+
145+
const speaker = message.role === 'user' ? 'User' : 'Assistant';
146+
const parts = [`## ${speaker}`, `*${message.timestamp}*`];
147+
if (content.trim()) {
148+
// Command output was never prose; keeping it fenced preserves it and
149+
// stops it being read as Markdown by whatever opens the file.
150+
parts.push(message.isLocalCommandStdout ? codeBlock(content) : content);
151+
}
152+
if (attachments > 0) {
153+
parts.push(`*${attachments} image attachment${attachments === 1 ? '' : 's'} not included in this export.*`);
154+
}
155+
return parts.join('\n\n');
156+
}
157+
}
158+
}
159+
160+
/**
161+
* Renders one session's stored transcript as a Markdown document.
162+
*
163+
* Deliberately not the runtime's `/export`: that is a builtin slash command
164+
* which only runs inside a live turn and writes a file into the project
165+
* directory. This reads the same persisted transcript the chat view reads, so
166+
* it works for any session - including one nobody is in - and produces a
167+
* download instead of an artifact in the user's repository.
168+
*
169+
* The provider is asked directly rather than through `sessionsService`, whose
170+
* history is prepared for transport and replaces tool output over 64KB with a
171+
* bounded preview. An export that quietly dropped the middle of a build log
172+
* would be worse than no export.
173+
*/
174+
export async function exportSessionTranscript(
175+
sessionId: string,
176+
exportedAt: Date = new Date(),
177+
): Promise<SessionExport> {
178+
const session = sessionsDb.getSessionById(sessionId);
179+
if (!session) {
180+
throw new AppError(`Session "${sessionId}" was not found.`, {
181+
code: 'SESSION_NOT_FOUND',
182+
statusCode: 404,
183+
});
184+
}
185+
186+
const projectPath = session.project_path ?? '';
187+
const title = session.custom_name?.trim()
188+
|| (projectPath ? path.basename(projectPath) : '')
189+
|| sessionId;
190+
191+
let messages: NormalizedMessage[] = [];
192+
if (session.provider_session_id) {
193+
const provider = providerRegistry.resolveProvider(session.provider as LLMProvider);
194+
const history = await provider.sessions.fetchHistory(sessionId, {
195+
limit: null,
196+
offset: 0,
197+
projectPath,
198+
providerSessionId: session.provider_session_id,
199+
});
200+
messages = history.messages;
201+
}
202+
203+
const rendered = messages
204+
.map(renderMessage)
205+
.filter((section): section is string => Boolean(section));
206+
207+
const header = [
208+
`# ${title}`,
209+
[
210+
`- Session: ${sessionId}`,
211+
`- Provider: ${session.provider}`,
212+
projectPath ? `- Project: ${projectPath}` : null,
213+
session.created_at ? `- Created: ${session.created_at}` : null,
214+
`- Exported: ${exportedAt.toISOString()}`,
215+
`- Messages: ${rendered.length}`,
216+
].filter(Boolean).join('\n'),
217+
].join('\n\n');
218+
219+
const body = rendered.length > 0
220+
? `${header}\n\n---\n\n${rendered.join('\n\n---\n\n')}\n`
221+
: `${header}\n\n---\n\n*This session has no recorded messages.*\n`;
222+
223+
const filename = exportFileName(title, sessionId, exportedAt);
224+
225+
return {
226+
filename,
227+
asciiFilename: asciiFileName(filename, sessionId, exportedAt),
228+
contentType: 'text/markdown; charset=utf-8',
229+
body,
230+
};
231+
}

0 commit comments

Comments
 (0)