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.
- copilot_session_tools/__init__.py +72 -0
- copilot_session_tools/cli.py +993 -0
- copilot_session_tools/database.py +1848 -0
- copilot_session_tools/html_exporter.py +270 -0
- copilot_session_tools/markdown_exporter.py +426 -0
- copilot_session_tools/py.typed +0 -0
- copilot_session_tools/scanner/__init__.py +73 -0
- copilot_session_tools/scanner/cli.py +606 -0
- copilot_session_tools/scanner/content.py +313 -0
- copilot_session_tools/scanner/diff.py +297 -0
- copilot_session_tools/scanner/discovery.py +365 -0
- copilot_session_tools/scanner/git.py +114 -0
- copilot_session_tools/scanner/models.py +122 -0
- copilot_session_tools/scanner/vscode.py +693 -0
- copilot_session_tools/web/__init__.py +107 -0
- copilot_session_tools/web/templates/error.html +61 -0
- copilot_session_tools/web/templates/index.html +1072 -0
- copilot_session_tools/web/templates/session.html +1592 -0
- copilot_session_tools/web/webapp.py +610 -0
- copilot_session_tools-0.1.1.dist-info/METADATA +375 -0
- copilot_session_tools-0.1.1.dist-info/RECORD +24 -0
- copilot_session_tools-0.1.1.dist-info/WHEEL +4 -0
- copilot_session_tools-0.1.1.dist-info/entry_points.txt +3 -0
- copilot_session_tools-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|