Skip to content

Commit dc1af1b

Browse files
committed
Address second round of CodeRabbit review comments
- utils/cli_chat_reader.py: populate tool outputs in messages_to_bubbles() by pre-scanning role=="tool" messages and matching by toolCallId. Tool outputs are no longer silently dropped (output was always ""). Use max(createdAt, store.db mtime) for last_updated_ms in list_cli_projects(), so sessions with new turns refresh the timestamp. - api/search.py: include tool call inputs/summaries in the exclusion-check content_parts for CLI sessions, so sensitive payloads in tool calls cannot bypass is_excluded_by_rules. - utils/cursor_md_exporter.py: extend cursor_cli_session_to_markdown() with workspace_info, bubbles, and title_override parameters. Callers can now pass pre-computed bubbles (avoids redundant DB read) and a caller-derived title (needed for filenames in export.py). Error handling changed: traverse_blobs/messages_to_bubbles errors now propagate instead of silently collapsing to empty bubbles. Body rendering now includes tool OUTPUT blocks when present. - scripts/export.py: remove the ~80-line duplicate Markdown generator; delegate to cursor_cli_session_to_markdown() with workspace_info and pre-computed bubbles. Keeps title derivation and --since filtering in place; only the rendering is delegated. - tests/test_cli_chat_reader.py: update last_updated_ms test to assert >= max(createdAt) instead of == createdAt, since mtime now dominates for freshly created test files. Rejected: "don't drop empty sessions" (same reasoning as before).
1 parent 1168507 commit dc1af1b

5 files changed

Lines changed: 106 additions & 85 deletions

File tree

api/search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,10 +378,15 @@ def search():
378378
break
379379

380380
bubble_texts = [b["text"] for b in bubbles if b.get("text")]
381+
tool_payloads = [
382+
tc.get("input") or tc.get("summary") or ""
383+
for b in bubbles
384+
for tc in (b.get("metadata") or {}).get("toolCalls") or []
385+
]
381386
exclusion_text = _build_exclusion_searchable(
382387
project_name=ws_name,
383388
chat_title=title,
384-
content_parts=bubble_texts,
389+
content_parts=bubble_texts + tool_payloads,
385390
)
386391
if is_excluded_by_rules(rules, exclusion_text):
387392
continue

scripts/export.py

Lines changed: 17 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
messages_to_bubbles,
3838
aggregate_session_stats,
3939
)
40+
from utils.cursor_md_exporter import cursor_cli_session_to_markdown
4041

4142
_logger = logging.getLogger(__name__)
4243

@@ -861,12 +862,13 @@ def assign_workspace(cd, cid):
861862
if not bubbles:
862863
continue
863864

864-
# Derive title from first user bubble
865+
# Derive title for the filename (shared exporter does it too, but
866+
# we need it here first to build the output path).
865867
title = session_name
866868
if not title or title.startswith("New Agent"):
867869
for b in bubbles:
868870
if b["type"] == "user" and b.get("text"):
869-
first_lines = [l for l in b["text"].split("\n") if l.strip()]
871+
first_lines = [ln for ln in b["text"].split("\n") if ln.strip()]
870872
if first_lines:
871873
title = first_lines[0][:100]
872874
if len(title) == 100:
@@ -887,73 +889,25 @@ def assign_workspace(cd, cid):
887889
if is_excluded_by_rules(exclusion_rules, searchable):
888890
continue
889891

890-
# Aggregate statistics
891-
total_tool_calls = 0
892-
tool_breakdown: dict = {}
893-
for b in bubbles:
894-
tcs = (b.get("metadata") or {}).get("toolCalls") or []
895-
total_tool_calls += len(tcs)
896-
for tc in tcs:
897-
tn = tc.get("name", "unknown")
898-
tool_breakdown[tn] = tool_breakdown.get(tn, 0) + 1
899-
900892
title_slug = slug(title)
901893
ts_str = datetime.fromtimestamp(created_ms / 1000).strftime("%Y-%m-%dT%H-%M-%S")
902894
filename = f"{ts_str}__{title_slug}__{session_id[:8]}.md"
903895
rel_dir = os.path.join(today, ws_slug_cli, "cli")
904896
out_path = os.path.join(out_dir, rel_dir, filename)
905897

906-
# Frontmatter — free-form scalars use json.dumps() for safe YAML quoting.
907-
fm_lines = ["---"]
908-
fm_lines.append(f"log_id: {json.dumps(session_id, ensure_ascii=False)}")
909-
fm_lines.append("log_type: cli_agent")
910-
fm_lines.append(f"title: {json.dumps(title, ensure_ascii=False)}")
911-
fm_lines.append(f"created_at: {datetime.fromtimestamp(created_ms / 1000).isoformat()}")
912-
fm_lines.append(f"workspace: {ws_slug_cli}")
913-
fm_lines.append(f"workspace_name: {json.dumps(ws_name, ensure_ascii=False)}")
914-
if cp.get("workspace_path"):
915-
fm_lines.append(f"workspace_path: {json.dumps(cp['workspace_path'], ensure_ascii=False)}")
916-
fm_lines.append(f"project_id: {json.dumps(cp['project_id'], ensure_ascii=False)}")
917-
fm_lines.append(f"session_id: {json.dumps(session_id, ensure_ascii=False)}")
918-
if meta.get("mode"):
919-
fm_lines.append(f"mode: {json.dumps(meta['mode'], ensure_ascii=False)}")
920-
fm_lines.append(f"message_count: {len(bubbles)}")
921-
if total_tool_calls:
922-
fm_lines.append(f"total_tool_calls: {total_tool_calls}")
923-
if tool_breakdown:
924-
fm_lines.append("tool_call_breakdown:")
925-
for tn, cnt in sorted(tool_breakdown.items(), key=lambda x: -x[1]):
926-
fm_lines.append(f" {json.dumps(tn, ensure_ascii=False)}: {cnt}")
927-
fm_lines.append("---")
928-
fm_str = "\n".join(fm_lines) + "\n\n"
929-
930-
# Header
931-
header_meta = [f"Created: {datetime.fromtimestamp(created_ms / 1000).strftime('%Y-%m-%d %H:%M:%S')}"]
932-
if meta.get("mode"):
933-
header_meta.append(f"Mode: {meta['mode']}")
934-
if total_tool_calls:
935-
header_meta.append(f"Tool calls: {total_tool_calls}")
936-
header = f"# {title}\n\n_{' | '.join(header_meta)}_\n\n---\n\n"
937-
938-
# Body
939-
body = ""
940-
for b in bubbles:
941-
role_label = "User" if b["type"] == "user" else "Assistant"
942-
body += f"### {role_label}\n\n"
943-
body += b.get("text", "") + "\n\n"
944-
tool_calls = (b.get("metadata") or {}).get("toolCalls") or []
945-
for tc in tool_calls:
946-
summary = tc.get("summary") or tc.get("name") or "unknown"
947-
body += f"> **Tool: {summary}**\n"
948-
if tc.get("input"):
949-
body += "> **INPUT:**\n> ```\n"
950-
for iline in str(tc["input"]).split("\n"):
951-
body += f"> {iline}\n"
952-
body += "> ```\n"
953-
body += "\n"
954-
body += "---\n\n"
955-
956-
md = fm_str + header + body
898+
# Delegate Markdown generation to the shared exporter.
899+
md = cursor_cli_session_to_markdown(
900+
session["db_path"],
901+
session_meta=meta,
902+
workspace_info={
903+
"workspace": ws_slug_cli,
904+
"workspace_name": ws_name,
905+
"workspace_path": cp.get("workspace_path"),
906+
"project_id": cp["project_id"],
907+
},
908+
bubbles=bubbles,
909+
title_override=title,
910+
)
957911
rel_path = os.path.join(today, ws_slug_cli, "cli", filename)
958912
exported.append({
959913
"id": session_id,

tests/test_cli_chat_reader.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,12 +474,15 @@ def test_sessions_grouped_by_project(self):
474474
self.assertIn("proj1", ids)
475475
self.assertIn("proj2", ids)
476476

477-
def test_last_updated_ms_uses_max_created_at(self):
477+
def test_last_updated_ms_is_at_least_max_created_at(self):
478+
# last_updated_ms = max(createdAt, store.db mtime) across all sessions.
479+
# Since test files are freshly created, mtime >= createdAt always; we only
480+
# assert the floor: the value must be >= max(createdAt) and > 0.
478481
self._make_session("proj1", "sess1", {"createdAt": 1000})
479482
self._make_session("proj1", "sess2", {"createdAt": 5000})
480483
projects = list_cli_projects(self.chats_dir)
481484
proj = next(p for p in projects if p["project_id"] == "proj1")
482-
self.assertEqual(proj["last_updated_ms"], 5000)
485+
self.assertGreaterEqual(proj["last_updated_ms"], 5000)
483486

484487
def test_workspace_name_extracted_from_user_info(self):
485488
ws_path = "/home/user/my-project"

utils/cli_chat_reader.py

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,24 +219,43 @@ def messages_to_bubbles(messages: list[dict], created_at_ms: int) -> list[dict]:
219219
- ``user`` messages: strip ``<user_info>`` preamble, keep query text.
220220
- ``assistant`` messages with text parts → ``type: "ai"`` bubble.
221221
- ``assistant`` messages with ``tool-call`` parts → ``type: "ai"`` bubble
222-
with ``metadata.toolCalls``.
223-
- ``tool`` (result) messages are skipped; their content is not separately
224-
surfaced (tool call IDs link them to the preceding assistant bubble).
222+
with ``metadata.toolCalls``. The ``output`` field of each tool call is
223+
populated from the corresponding ``role: "tool"`` result message (matched
224+
by ``toolCallId``).
225+
- ``tool`` (result) messages are used only to populate tool-call outputs;
226+
they are not emitted as separate bubbles.
225227
226228
Per-message timestamps are unavailable in CLI sessions; ``created_at_ms``
227229
is used for all bubbles with a 1 ms sequence offset so UI sorting is stable.
228230
"""
231+
# Pre-scan: build toolCallId → output text map from role=="tool" messages.
232+
tool_outputs: dict[str, str] = {}
233+
for msg in messages:
234+
if msg.get("role") != "tool":
235+
continue
236+
content = msg.get("content", "")
237+
if isinstance(content, list):
238+
for part in content:
239+
if not isinstance(part, dict):
240+
continue
241+
if part.get("type") == "tool-result":
242+
tid = part.get("toolCallId", "")
243+
result = part.get("result", "")
244+
if tid:
245+
tool_outputs[tid] = str(result) if result else ""
246+
elif isinstance(content, str) and content:
247+
# Plain string result with no toolCallId — store under empty key
248+
# only if not already set, to avoid clobbering a keyed entry.
249+
tool_outputs.setdefault("", content)
250+
229251
bubbles: list[dict] = []
230252
seq = 0
231253

232254
for msg in messages:
233255
role = msg.get("role", "")
234256
content = msg.get("content", "")
235257

236-
if role == "system":
237-
continue
238-
239-
if role == "tool":
258+
if role in ("system", "tool"):
240259
continue
241260

242261
ts = created_at_ms + seq
@@ -266,6 +285,7 @@ def messages_to_bubbles(messages: list[dict], created_at_ms: int) -> list[dict]:
266285
for tc in tool_calls:
267286
args = tc.get("args", {})
268287
name = tc.get("name", "unknown")
288+
tid = tc.get("toolCallId", "")
269289
# Build a human-readable summary
270290
if name in ("Glob", "glob_file_search"):
271291
pattern = args.get("glob_pattern") or args.get("pattern") or ""
@@ -299,8 +319,8 @@ def messages_to_bubbles(messages: list[dict], created_at_ms: int) -> list[dict]:
299319
"status": "",
300320
"summary": summary,
301321
"input": input_display,
302-
"output": "",
303-
"toolCallId": tc.get("toolCallId", ""),
322+
"output": tool_outputs.get(tid, ""),
323+
"toolCallId": tid,
304324
})
305325
if not text.strip():
306326
tc0 = formatted_calls[0]
@@ -371,8 +391,13 @@ def list_cli_projects(chats_path: str) -> list[dict]:
371391
projects[pid]["sessions"].append(session)
372392

373393
created = session["meta"].get("createdAt") or 0
374-
if created > projects[pid]["last_updated_ms"]:
375-
projects[pid]["last_updated_ms"] = created
394+
try:
395+
mtime_ms = int(os.path.getmtime(session["db_path"]) * 1000)
396+
except OSError:
397+
mtime_ms = 0
398+
session_ts = max(created, mtime_ms)
399+
if session_ts > projects[pid]["last_updated_ms"]:
400+
projects[pid]["last_updated_ms"] = session_ts
376401

377402
# Resolve workspace path / name from a session's messages
378403
for pid, proj in projects.items():

utils/cursor_md_exporter.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22
33
Exposes ``cursor_cli_session_to_markdown`` — a reusable function that
44
generates a complete Markdown document (YAML frontmatter + body) from a
5-
Cursor CLI ``store.db`` session. The logic is extracted from
6-
``scripts/export.py`` so it can be called programmatically without running
7-
the full export CLI.
5+
Cursor CLI ``store.db`` session. The logic is shared between
6+
``scripts/export.py`` and any programmatic caller.
87
"""
98

109
from __future__ import annotations
1110

1211
import json
1312
from datetime import datetime
1413
from pathlib import Path
14+
from typing import TYPE_CHECKING
1515

1616
from utils.cli_chat_reader import traverse_blobs, messages_to_bubbles
1717

@@ -28,6 +28,9 @@ def _slug(s: str) -> str:
2828
def cursor_cli_session_to_markdown(
2929
db_path: str | Path,
3030
session_meta: dict | None = None,
31+
workspace_info: dict | None = None,
32+
bubbles: list[dict] | None = None,
33+
title_override: str | None = None,
3134
) -> str:
3235
"""Generate a complete Markdown document from a Cursor CLI store.db session.
3336
@@ -39,11 +42,28 @@ def cursor_cli_session_to_markdown(
3942
Optional dict with pre-read session metadata (keys: ``agentId``,
4043
``createdAt``, ``name``, ``mode``). If omitted, metadata is read
4144
from ``db_path`` automatically.
45+
workspace_info:
46+
Optional dict with workspace-level fields to include in frontmatter.
47+
Recognised keys: ``workspace`` (slug), ``workspace_name``,
48+
``workspace_path``, ``project_id``.
49+
bubbles:
50+
Pre-computed bubble list from ``messages_to_bubbles()``. When
51+
provided the database is not re-read, avoiding a redundant SQL query.
52+
title_override:
53+
Caller-supplied title (e.g. already derived for a filename). When
54+
set, skips the first-user-message derivation heuristic.
4255
4356
Returns
4457
-------
4558
str
4659
Full Markdown text including YAML frontmatter and conversation body.
60+
61+
Raises
62+
------
63+
Exception
64+
Re-raises any exception from ``traverse_blobs`` / ``messages_to_bubbles``
65+
so callers can detect unreadable databases rather than silently receiving
66+
an empty document.
4767
"""
4868
db_path = Path(db_path)
4969

@@ -63,15 +83,14 @@ def cursor_cli_session_to_markdown(
6383
session_name: str = session_meta.get("name") or f"Session {session_id[:8]}"
6484
mode: str = session_meta.get("mode", "")
6585

66-
# Reconstruct conversation.
67-
try:
86+
# Reconstruct conversation — callers may pass pre-computed bubbles to
87+
# avoid a redundant DB read. Errors propagate; caller decides how to handle.
88+
if bubbles is None:
6889
messages = traverse_blobs(str(db_path))
6990
bubbles = messages_to_bubbles(messages, created_ms)
70-
except Exception:
71-
bubbles = []
7291

7392
# Derive title.
74-
title = session_name
93+
title = title_override or session_name
7594
if not title or title.startswith("New Agent"):
7695
for b in bubbles:
7796
if b["type"] == "user" and b.get("text"):
@@ -102,6 +121,16 @@ def cursor_cli_session_to_markdown(
102121
fm_lines.append(
103122
f"created_at: {datetime.fromtimestamp(created_ms / 1000).isoformat()}"
104123
)
124+
# Workspace-level fields (only when caller provides them).
125+
ws_info = workspace_info or {}
126+
if ws_info.get("workspace"):
127+
fm_lines.append(f"workspace: {ws_info['workspace']}")
128+
if ws_info.get("workspace_name"):
129+
fm_lines.append(f"workspace_name: {json.dumps(ws_info['workspace_name'], ensure_ascii=False)}")
130+
if ws_info.get("workspace_path"):
131+
fm_lines.append(f"workspace_path: {json.dumps(ws_info['workspace_path'], ensure_ascii=False)}")
132+
if ws_info.get("project_id"):
133+
fm_lines.append(f"project_id: {json.dumps(ws_info['project_id'], ensure_ascii=False)}")
105134
fm_lines.append(f"session_id: {json.dumps(session_id, ensure_ascii=False)}")
106135
if mode:
107136
fm_lines.append(f"mode: {json.dumps(mode, ensure_ascii=False)}")
@@ -140,6 +169,11 @@ def cursor_cli_session_to_markdown(
140169
for iline in str(tc["input"]).split("\n"):
141170
body += f"> {iline}\n"
142171
body += "> ```\n"
172+
if tc.get("output"):
173+
body += "> **OUTPUT:**\n> ```\n"
174+
for oline in str(tc["output"]).split("\n"):
175+
body += f"> {oline}\n"
176+
body += "> ```\n"
143177
body += "\n"
144178
body += "---\n\n"
145179

0 commit comments

Comments
 (0)