copilot-session-tools 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ """HTML exporter for Copilot chat sessions.
2
+
3
+ Renders chat sessions as self-contained static HTML files using the same
4
+ Jinja2 template as the web viewer, but with interactive elements (toolbar,
5
+ AJAX, copy buttons) stripped out via the `static=True` flag.
6
+ """
7
+
8
+ import re
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from urllib.parse import unquote
12
+
13
+ import markdown
14
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
15
+ from markupsafe import Markup
16
+
17
+ from .markdown_exporter import generate_session_filename as _md_generate_filename
18
+ from .scanner import ChatSession
19
+
20
+ # Threshold to distinguish between seconds and milliseconds timestamps.
21
+ _MILLISECONDS_THRESHOLD = 1e12
22
+
23
+ # Create a reusable markdown converter with extensions
24
+ _md_converter = markdown.Markdown(
25
+ extensions=[
26
+ "tables",
27
+ "fenced_code",
28
+ "sane_lists",
29
+ "smarty",
30
+ "nl2br",
31
+ ],
32
+ extension_configs={
33
+ "smarty": {
34
+ "smart_dashes": True,
35
+ "smart_quotes": True,
36
+ },
37
+ },
38
+ )
39
+
40
+ # Regex pattern for ANSI escape codes
41
+ _ANSI_ESCAPE_PATTERN = re.compile(
42
+ r"\x1b"
43
+ r"(?:"
44
+ r"\[[0-9;]*[A-Za-z]"
45
+ r"|"
46
+ r"\][^\x07]*\x07"
47
+ r"|"
48
+ r"\][^\x1b]*\x1b\\"
49
+ r")"
50
+ )
51
+
52
+
53
+ def _markdown_to_html(text: str) -> str:
54
+ """Convert markdown text to HTML using the markdown library."""
55
+ if not text:
56
+ return ""
57
+
58
+ text = text.replace("\r\n", "\n")
59
+
60
+ def extract_filename_from_file_uri(uri: str) -> str:
61
+ decoded = unquote(uri)
62
+ path = decoded.replace("file:///", "").split("#")[0]
63
+ if "/" in path:
64
+ return path.split("/")[-1]
65
+ if "\\" in path:
66
+ return path.split("\\")[-1]
67
+ return path
68
+
69
+ def replace_empty_file_link(match):
70
+ uri = match.group(1)
71
+ filename = extract_filename_from_file_uri(uri)
72
+ return f"`{filename}`"
73
+
74
+ text = re.sub(r"\[\]\(file://([^)]+)\)", replace_empty_file_link, text)
75
+ text = re.sub(r'^(Using ["""][^"""]+["""])$', r"_\1_", text, flags=re.MULTILINE)
76
+ text = re.sub(r"_Edited `([^`]+)`_", r"_Edited \1_", text)
77
+ text = re.sub(r"^(Ran terminal command:.*)$", r"_\1_", text, flags=re.MULTILINE)
78
+ text = re.sub(r"^((?:Now )?[Ll]et me [^:]+:)$", r"_\1_", text, flags=re.MULTILINE)
79
+ text = re.sub(r"^(Made changes\.)$", r"_\1_", text, flags=re.MULTILINE)
80
+
81
+ _md_converter.reset()
82
+ return Markup(_md_converter.convert(text)) # noqa: S704 - markdown output is intentionally rendered as HTML
83
+
84
+
85
+ def _urldecode(text: str) -> str:
86
+ if not text:
87
+ return ""
88
+ return unquote(text)
89
+
90
+
91
+ def _strip_ansi(text: str | None) -> str:
92
+ if not text:
93
+ return ""
94
+ return _ANSI_ESCAPE_PATTERN.sub("", text)
95
+
96
+
97
+ def _format_timestamp(value: str) -> str:
98
+ if not value:
99
+ return ""
100
+ try:
101
+ epoch_ms = float(value)
102
+ epoch_s = epoch_ms / 1000
103
+ dt = datetime.fromtimestamp(epoch_s)
104
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
105
+ except (ValueError, TypeError, OSError):
106
+ return str(value)
107
+
108
+
109
+ def _parse_diff_stats(diff: str) -> dict:
110
+ if not diff:
111
+ return {"additions": 0, "deletions": 0}
112
+ additions = 0
113
+ deletions = 0
114
+ for line in diff.split("\n"):
115
+ if line.startswith("+++") or line.startswith("---") or line.startswith("@@"):
116
+ continue
117
+ if line.startswith("+"):
118
+ additions += 1
119
+ elif line.startswith("-"):
120
+ deletions += 1
121
+ return {"additions": additions, "deletions": deletions}
122
+
123
+
124
+ def _extract_filename(path: str) -> str:
125
+ if not path:
126
+ return ""
127
+ if "/" in path:
128
+ return path.split("/")[-1]
129
+ if "\\" in path:
130
+ return path.split("\\")[-1]
131
+ return path
132
+
133
+
134
+ def _match_tool_for_block(block_content: str, tools: list, used_indices: set) -> tuple:
135
+ """Match a tool invocation block content to a tool from the list."""
136
+ if not tools:
137
+ return None, used_indices
138
+
139
+ match = re.search(r"`([^`]+)`", block_content)
140
+ short_name = match.group(1) if match else None
141
+
142
+ if not short_name:
143
+ match = re.search(r"Running\s+(\S+)", block_content)
144
+ short_name = match.group(1) if match else None
145
+
146
+ if short_name:
147
+ for i, tool in enumerate(tools):
148
+ if i in used_indices:
149
+ continue
150
+ if short_name.lower() in tool.name.lower() or tool.name.lower().endswith(short_name.lower()):
151
+ used_indices = used_indices | {i}
152
+ return tool, used_indices
153
+
154
+ for i, tool in enumerate(tools):
155
+ if i not in used_indices:
156
+ used_indices = used_indices | {i}
157
+ return tool, used_indices
158
+
159
+ return None, used_indices
160
+
161
+
162
+ def _get_jinja_env() -> Environment:
163
+ """Create a standalone Jinja2 environment pointing at the web templates."""
164
+ templates_dir = Path(__file__).parent / "web" / "templates"
165
+ env = Environment(
166
+ loader=FileSystemLoader(str(templates_dir)),
167
+ autoescape=select_autoescape(["html"]),
168
+ )
169
+ env.filters["markdown"] = _markdown_to_html
170
+ env.filters["urldecode"] = _urldecode
171
+ env.filters["format_timestamp"] = _format_timestamp
172
+ env.filters["parse_diff_stats"] = _parse_diff_stats
173
+ env.filters["extract_filename"] = _extract_filename
174
+ env.filters["strip_ansi"] = _strip_ansi
175
+ env.globals["match_tool_for_block"] = _match_tool_for_block
176
+ return env
177
+
178
+
179
+ def _preprocess_messages(session: ChatSession) -> tuple[str | None, dict[int, dict]]:
180
+ """Pre-process messages to match tool invocations with content blocks.
181
+
182
+ Same logic as the webapp session_view route. Returns first user prompt
183
+ and a dict mapping message index to its block metadata.
184
+ """
185
+ first_user_prompt = None
186
+ message_metadata: dict[int, dict] = {}
187
+ for msg_idx, message in enumerate(session.messages):
188
+ if message.role == "user" and first_user_prompt is None:
189
+ first_user_prompt = message.content
190
+
191
+ block_tool_map = {}
192
+ block_cmd_map = {}
193
+ used_tool_indices: set = set()
194
+ used_cmd_indices: set = set()
195
+
196
+ for i, block in enumerate(message.content_blocks):
197
+ if block.kind == "toolInvocation":
198
+ if message.command_runs and block.content.startswith("$"):
199
+ cmd_text = block.content[1:].strip()
200
+ for j, cmd in enumerate(message.command_runs):
201
+ if j in used_cmd_indices:
202
+ continue
203
+ if cmd.command and (cmd.command.startswith(cmd_text[:30]) or cmd_text[:30] in cmd.command):
204
+ block_cmd_map[i] = cmd
205
+ used_cmd_indices.add(j)
206
+ break
207
+
208
+ if i not in block_cmd_map and message.tool_invocations:
209
+ matched_tool, used_tool_indices = _match_tool_for_block(block.content, message.tool_invocations, used_tool_indices)
210
+ if matched_tool:
211
+ block_tool_map[i] = matched_tool
212
+
213
+ message_metadata[msg_idx] = {
214
+ "block_tool_map": block_tool_map,
215
+ "block_cmd_map": block_cmd_map,
216
+ "matched_tool_names": {t.name for t in block_tool_map.values()},
217
+ "matched_cmd_indices": used_cmd_indices,
218
+ }
219
+
220
+ return first_user_prompt, message_metadata
221
+
222
+
223
+ def session_to_html(session: ChatSession) -> str:
224
+ """Convert a chat session to a self-contained static HTML string.
225
+
226
+ Args:
227
+ session: The ChatSession to convert.
228
+
229
+ Returns:
230
+ Complete HTML document as a string.
231
+ """
232
+ first_user_prompt, message_metadata = _preprocess_messages(session)
233
+ env = _get_jinja_env()
234
+ template = env.get_template("session.html")
235
+ return template.render(
236
+ title=session.custom_title or session.workspace_name or f"Session {session.session_id[:8]}",
237
+ session=session,
238
+ message_count=len(session.messages),
239
+ first_user_prompt=first_user_prompt,
240
+ message_metadata=message_metadata,
241
+ static=True,
242
+ )
243
+
244
+
245
+ def export_session_to_html_file(
246
+ session: ChatSession,
247
+ output_path: Path | str,
248
+ ) -> None:
249
+ """Export a single session to a static HTML file.
250
+
251
+ Args:
252
+ session: The ChatSession to export.
253
+ output_path: Path to the output HTML file.
254
+ """
255
+ html = session_to_html(session)
256
+ Path(output_path).write_text(html, encoding="utf-8")
257
+
258
+
259
+ def generate_session_html_filename(session: ChatSession) -> str:
260
+ """Generate a filename for a session's HTML export.
261
+
262
+ Args:
263
+ session: The ChatSession to generate a filename for.
264
+
265
+ Returns:
266
+ A safe filename string with .html extension.
267
+ """
268
+ # Reuse markdown filename logic but swap extension
269
+ md_filename = _md_generate_filename(session)
270
+ return md_filename.rsplit(".", 1)[0] + ".html"
@@ -0,0 +1,426 @@
1
+ """Markdown exporter for Copilot chat sessions.
2
+
3
+ Exports chat sessions to markdown format with:
4
+ - Header block with metadata (session ID, workspace, dates)
5
+ - Messages separated by horizontal rules
6
+ - Message numbers and roles as bold headers
7
+ - Tool call summaries in italics
8
+ - Thinking block notices in italics (without the full content)
9
+ """
10
+
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from urllib.parse import unquote
14
+
15
+ from .scanner import ChatMessage, ChatSession
16
+
17
+ # Threshold to distinguish between seconds and milliseconds timestamps.
18
+ # Timestamps above this value (approximately year 2001 in milliseconds) are
19
+ # treated as milliseconds and divided by 1000 to convert to seconds.
20
+ _MILLISECONDS_THRESHOLD = 1e12
21
+
22
+
23
+ def _format_timestamp(value: str | int | None) -> str:
24
+ """Format an epoch timestamp (milliseconds) to a human-readable date string."""
25
+ if not value:
26
+ return "Unknown"
27
+ try:
28
+ # Handle both string and numeric values - float() accepts both
29
+ numeric_value = float(value)
30
+ # Check if milliseconds (common for JS timestamps)
31
+ if numeric_value > _MILLISECONDS_THRESHOLD:
32
+ numeric_value = numeric_value / 1000
33
+ dt = datetime.fromtimestamp(numeric_value)
34
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
35
+ except (ValueError, TypeError, OSError):
36
+ # If parsing fails, return original value as string
37
+ return str(value)
38
+
39
+
40
+ def _urldecode(text: str) -> str:
41
+ """Decode URL-encoded text (e.g., 'c%3A' -> 'c:')."""
42
+ if not text:
43
+ return ""
44
+ return unquote(text)
45
+
46
+
47
+ def _format_tool_summary(message: ChatMessage, include_inputs: bool = False) -> str:
48
+ """Format tool invocations as an italicized summary line.
49
+
50
+ Args:
51
+ message: The message containing tool invocations.
52
+ include_inputs: If True, include tool inputs as code blocks.
53
+ """
54
+ if not message.tool_invocations:
55
+ return ""
56
+
57
+ tool_names = [tool.name for tool in message.tool_invocations]
58
+ count = len(tool_names)
59
+
60
+ if count == 1:
61
+ summary = f"\n\n*Used tool: {tool_names[0]}*"
62
+ elif count <= 3:
63
+ summary = f"\n\n*Used tools: {', '.join(tool_names)}*"
64
+ else:
65
+ summary = f"\n\n*Used {count} tools: {', '.join(tool_names[:3])}, ...*"
66
+
67
+ if include_inputs:
68
+ for tool in message.tool_invocations:
69
+ if tool.input:
70
+ summary += f"\n\n**{tool.name} input:**\n```\n{tool.input}\n```"
71
+
72
+ return summary
73
+
74
+
75
+ def _format_file_changes_summary(message: ChatMessage, include_diffs: bool = False) -> str:
76
+ """Format file changes as an italicized summary line.
77
+
78
+ Args:
79
+ message: The message containing file changes.
80
+ include_diffs: If True, include file diffs as code blocks.
81
+ """
82
+ if not message.file_changes:
83
+ return ""
84
+
85
+ paths = [change.path for change in message.file_changes]
86
+ count = len(paths)
87
+
88
+ if count == 1:
89
+ summary = f"\n\n*Changed file: {paths[0]}*"
90
+ elif count <= 3:
91
+ summary = f"\n\n*Changed files: {', '.join(paths)}*"
92
+ else:
93
+ summary = f"\n\n*Changed {count} files: {', '.join(paths[:3])}, ...*"
94
+
95
+ if include_diffs:
96
+ for change in message.file_changes:
97
+ if change.diff:
98
+ summary += f"\n\n**{change.path}:**\n```diff\n{change.diff}\n```"
99
+
100
+ return summary
101
+
102
+
103
+ def _format_command_runs_summary(message: ChatMessage) -> str:
104
+ """Format command runs as an italicized summary line."""
105
+ if not message.command_runs:
106
+ return ""
107
+
108
+ count = len(message.command_runs)
109
+
110
+ if count == 1:
111
+ cmd = message.command_runs[0]
112
+ if cmd.title:
113
+ cmd_display = cmd.title
114
+ else:
115
+ cmd_display = cmd.command[:50] + "..." if len(cmd.command) > 50 else cmd.command
116
+ return f"\n\n*Ran command: `{cmd_display}`*"
117
+ else:
118
+ return f"\n\n*Ran {count} commands*"
119
+
120
+
121
+ def _had_thinking_content(message: ChatMessage) -> bool:
122
+ """Check if the message had any thinking blocks."""
123
+ if not message.content_blocks:
124
+ return False
125
+ return any(block.kind == "thinking" for block in message.content_blocks)
126
+
127
+
128
+ def _has_inline_tool_blocks(message: ChatMessage) -> bool:
129
+ """Check if the message has inline tool invocation blocks.
130
+
131
+ When tools are rendered inline (VSCode-style), we don't need to add
132
+ a separate tool summary at the end of the message.
133
+ """
134
+ if not message.content_blocks:
135
+ return False
136
+ return any(block.kind == "toolInvocation" for block in message.content_blocks)
137
+
138
+
139
+ def _format_message_content(message: ChatMessage, include_thinking: bool = False) -> str:
140
+ """Format message content, optionally including thinking blocks.
141
+
142
+ Args:
143
+ message: The ChatMessage to format.
144
+ include_thinking: If True, include thinking block content. If False, show
145
+ a notice that thinking occurred but omit the content.
146
+
147
+ Returns:
148
+ Formatted message content as a string.
149
+ """
150
+ parts = []
151
+
152
+ # Check if there was thinking content
153
+ had_thinking = _had_thinking_content(message)
154
+
155
+ if message.content_blocks:
156
+ # Use structured content blocks
157
+ for block in message.content_blocks:
158
+ if block.kind == "thinking":
159
+ if include_thinking:
160
+ # Include the actual thinking content in a blockquote
161
+ parts.append(f"> **Thinking:**\n> {block.content.replace(chr(10), chr(10) + '> ')}")
162
+ # If not including, we'll add a notice at the start
163
+ continue
164
+ elif block.kind == "toolInvocation":
165
+ # For command runs, description holds the human-readable title
166
+ # (content starts with "$ " and is the raw command)
167
+ # For regular tools, content is already the pretty invocation message
168
+ if block.description and block.content.startswith("$ "):
169
+ display = block.description
170
+ else:
171
+ display = block.content.strip()
172
+ if display:
173
+ parts.append(f"*{display}*")
174
+ else:
175
+ # Only add non-empty text blocks
176
+ if block.content.strip():
177
+ parts.append(block.content)
178
+ else:
179
+ # Fall back to flat content
180
+ if message.content.strip():
181
+ parts.append(message.content)
182
+
183
+ content = "\n\n".join(parts)
184
+
185
+ # Post-process to normalize formatting patterns
186
+ import re
187
+
188
+ # "*Creating [](file://...)*" -> "*Creating filename*" (extract leaf name, keep italics, remove link)
189
+ content = re.sub(r"\*Creating \[\]\(file://[^)]+/([^/)]+)\)\*", r"*Creating \1*", content)
190
+
191
+ # "*Reading [](file://...)*" -> "*Reading filename*" (extract leaf name, keep italics, remove link)
192
+ content = re.sub(r"\*Reading \[\]\(file://[^)]+/([^/)]+)\)\*", r"*Reading \1*", content)
193
+
194
+ # "*Edited `filename`*" -> "*Edited filename*" (remove backticks within italics)
195
+ content = re.sub(r"\*Edited `([^`]+)`\*", r"*Edited \1*", content)
196
+
197
+ # Add thinking notice if there was thinking content (and not already included)
198
+ if had_thinking and not include_thinking:
199
+ content = "*[Was thinking...]*\n\n" + content
200
+
201
+ return content
202
+
203
+
204
+ def session_to_markdown(
205
+ session: ChatSession,
206
+ include_diffs: bool = False,
207
+ include_tool_inputs: bool = False,
208
+ include_thinking: bool = False,
209
+ ) -> str:
210
+ """Convert a chat session to markdown format.
211
+
212
+ Args:
213
+ session: The ChatSession to convert.
214
+ include_diffs: If True, include file diffs as code blocks.
215
+ include_tool_inputs: If True, include tool inputs as code blocks.
216
+ include_thinking: If True, include thinking block content. If False (default),
217
+ show a notice that thinking occurred but omit the content.
218
+
219
+ Returns:
220
+ Markdown string representation of the session.
221
+ """
222
+ lines = []
223
+
224
+ # Header block with metadata
225
+ lines.append("# Chat Session")
226
+ lines.append("")
227
+
228
+ # Session title/name
229
+ if session.custom_title:
230
+ lines.append(f"**Title:** {session.custom_title}")
231
+ elif session.workspace_name:
232
+ lines.append(f"**Workspace:** {session.workspace_name}")
233
+ else:
234
+ lines.append(f"**Session:** {session.session_id[:8]}...")
235
+
236
+ lines.append("")
237
+
238
+ # Metadata in a clear format
239
+ lines.append("## Metadata")
240
+ lines.append("")
241
+ lines.append(f"- **Session ID:** `{session.session_id}`")
242
+
243
+ if session.workspace_name:
244
+ lines.append(f"- **Workspace:** {session.workspace_name}")
245
+
246
+ if session.workspace_path:
247
+ decoded_path = _urldecode(session.workspace_path)
248
+ lines.append(f"- **Path:** `{decoded_path}`")
249
+
250
+ if session.created_at:
251
+ lines.append(f"- **Created:** {_format_timestamp(session.created_at)}")
252
+
253
+ if session.updated_at:
254
+ lines.append(f"- **Updated:** {_format_timestamp(session.updated_at)}")
255
+
256
+ lines.append(f"- **Edition:** `{session.vscode_edition}`")
257
+ lines.append(f"- **Messages:** {len(session.messages)}")
258
+
259
+ if session.requester_username:
260
+ lines.append(f"- **User:** {session.requester_username}")
261
+
262
+ if session.responder_username:
263
+ lines.append(f"- **Assistant:** {session.responder_username}")
264
+
265
+ lines.append("")
266
+ lines.append("---")
267
+ lines.append("")
268
+
269
+ # Messages
270
+ for i, message in enumerate(session.messages, 1):
271
+ msg_md = message_to_markdown(
272
+ message,
273
+ message_number=i,
274
+ include_diffs=include_diffs,
275
+ include_tool_inputs=include_tool_inputs,
276
+ include_thinking=include_thinking,
277
+ )
278
+ lines.append(msg_md)
279
+
280
+ return "\n".join(lines)
281
+
282
+
283
+ def message_to_markdown(
284
+ message: ChatMessage,
285
+ message_number: int = 0,
286
+ include_diffs: bool = False,
287
+ include_tool_inputs: bool = False,
288
+ include_thinking: bool = False,
289
+ ) -> str:
290
+ """Convert a single message to markdown format.
291
+
292
+ Args:
293
+ message: The ChatMessage to convert.
294
+ message_number: The 1-based message number (0 means don't include header).
295
+ include_diffs: If True, include file diffs as code blocks.
296
+ include_tool_inputs: If True, include tool inputs as code blocks.
297
+ include_thinking: If True, include thinking block content.
298
+
299
+ Returns:
300
+ Markdown string representation of the message.
301
+ """
302
+ lines = []
303
+
304
+ # Message header: number and role (if message_number > 0)
305
+ if message_number > 0:
306
+ role_display = message.role.upper()
307
+ lines.append(f"## Message {message_number}: **{role_display}**")
308
+ lines.append("")
309
+
310
+ # Timestamp if available
311
+ if message.timestamp:
312
+ lines.append(f"*{_format_timestamp(message.timestamp)}*")
313
+ lines.append("")
314
+
315
+ # Content (optionally including thinking blocks)
316
+ content = _format_message_content(message, include_thinking=include_thinking)
317
+ lines.append(content)
318
+
319
+ # Check if tools are rendered inline via content blocks
320
+ # If so, skip the separate tool/command summaries to avoid duplication
321
+ has_inline_tools = _has_inline_tool_blocks(message)
322
+
323
+ if not has_inline_tools:
324
+ # Tool invocations summary (in italics, with optional inputs)
325
+ tool_summary = _format_tool_summary(message, include_inputs=include_tool_inputs)
326
+ if tool_summary:
327
+ lines.append(tool_summary)
328
+
329
+ # Command runs summary (in italics)
330
+ cmd_summary = _format_command_runs_summary(message)
331
+ if cmd_summary:
332
+ lines.append(cmd_summary)
333
+
334
+ # File changes summary (in italics, with optional diffs) - always include
335
+ file_summary = _format_file_changes_summary(message, include_diffs=include_diffs)
336
+ if file_summary:
337
+ lines.append(file_summary)
338
+
339
+ lines.append("")
340
+ lines.append("---")
341
+ lines.append("")
342
+
343
+ return "\n".join(lines)
344
+
345
+
346
+ def export_session_to_file(
347
+ session: ChatSession,
348
+ output_path: Path | str,
349
+ include_diffs: bool = False,
350
+ include_tool_inputs: bool = False,
351
+ include_thinking: bool = False,
352
+ ) -> None:
353
+ """Export a single session to a markdown file.
354
+
355
+ Args:
356
+ session: The ChatSession to export.
357
+ output_path: Path to the output markdown file.
358
+ include_diffs: If True, include file diffs as code blocks.
359
+ include_tool_inputs: If True, include tool inputs as code blocks.
360
+ include_thinking: If True, include thinking block content in the export.
361
+ """
362
+ markdown = session_to_markdown(
363
+ session,
364
+ include_diffs=include_diffs,
365
+ include_tool_inputs=include_tool_inputs,
366
+ include_thinking=include_thinking,
367
+ )
368
+ Path(output_path).write_text(markdown, encoding="utf-8")
369
+
370
+
371
+ def _sanitize_filename(name: str, max_length: int = 50) -> str:
372
+ """Sanitize a string to be safe for use as a filename.
373
+
374
+ Replaces any characters that are not alphanumeric, hyphen, underscore,
375
+ or period with underscores. Also limits the length.
376
+
377
+ Args:
378
+ name: The string to sanitize.
379
+ max_length: Maximum length of the resulting string.
380
+
381
+ Returns:
382
+ A filesystem-safe string.
383
+ """
384
+ safe_name = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in name)
385
+ return safe_name[:max_length]
386
+
387
+
388
+ def generate_session_filename(session: ChatSession) -> str:
389
+ """Generate a filename for a session's markdown export.
390
+
391
+ Args:
392
+ session: The ChatSession to generate a filename for.
393
+
394
+ Returns:
395
+ A safe filename string.
396
+ """
397
+ # Use custom title, workspace name, or session ID prefix
398
+ if session.custom_title:
399
+ name = session.custom_title
400
+ elif session.workspace_name:
401
+ name = session.workspace_name
402
+ else:
403
+ name = session.session_id[:16]
404
+
405
+ # Add date if available
406
+ date_str = ""
407
+ if session.created_at:
408
+ try:
409
+ ts = session.created_at
410
+ if isinstance(ts, str):
411
+ ts = float(ts)
412
+ if ts > _MILLISECONDS_THRESHOLD:
413
+ ts = ts / 1000
414
+ date_str = datetime.fromtimestamp(ts).strftime("%Y%m%d")
415
+ except (ValueError, TypeError, OSError):
416
+ pass
417
+
418
+ # Create safe filename
419
+ safe_name = _sanitize_filename(name)
420
+
421
+ if date_str:
422
+ filename = f"{date_str}_{safe_name}_{session.session_id[:8]}.md"
423
+ else:
424
+ filename = f"{safe_name}_{session.session_id[:8]}.md"
425
+
426
+ return filename
File without changes