Skip to content

Commit 1168507

Browse files
committed
Address CodeRabbit review comments on #7 PR
- api/search.py: skip CLI session scan for type=chat/composer; only runs when search_type == "all". Fix ambiguous variable l -> ln. - utils/cli_chat_reader.py: reverse messages at end of traverse_blobs() to restore chronological (oldest→newest) order; switch list.pop(0) to deque.popleft() for efficiency. - utils/workspace_path.py: strip() and expand_tilde_path() the CLI_CHATS_PATH env var before returning, matching resolve_workspace_path(). - utils/cursor_md_exporter.py: use json.dumps() for all free-form YAML scalar values (title, session_id, mode, tool names) so backslashes, newlines, and embedded quotes are escaped correctly. - scripts/export.py: use store.db mtime (max createdAt, mtime) as updated_ms for --since last filtering and manifest updatedAt, so sessions with new turns are re-exported. Expand exclusion check to full transcript + tool call payloads instead of first 5 bubbles. Apply json.dumps() YAML escaping to frontmatter (same fix as exporter). - tests: update assertions to match json.dumps() quoting; fix _simple_session() fixture to model the real linked-list chain layout; replace test_chain_to_json_blobs with test_chain_preserves_chronological_order which asserts exact BFS-then-reverse ordering. Rejected: "don't drop empty sessions" — empty bubbles means the store.db is unreadable or the session has no turns; emitting a 0-message file adds noise without value.
1 parent c58bdf1 commit 1168507

7 files changed

Lines changed: 146 additions & 113 deletions

File tree

api/search.py

Lines changed: 70 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -345,80 +345,81 @@ def search():
345345
pass
346346

347347
# ---------------------------------------------------------------
348-
# Search Cursor CLI sessions
348+
# Search Cursor CLI sessions (only for type=all)
349349
# ---------------------------------------------------------------
350-
try:
351-
cli_projects = list_cli_projects(get_cli_chats_path())
352-
for cp in cli_projects:
353-
ws_name = cp["workspace_name"] or cp["project_id"][:12]
354-
for session in cp["sessions"]:
355-
meta = session.get("meta", {})
356-
session_id = session["session_id"]
357-
created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000)
358-
session_name = meta.get("name") or f"Session {session_id[:8]}"
350+
if search_type == "all":
351+
try:
352+
cli_projects = list_cli_projects(get_cli_chats_path())
353+
for cp in cli_projects:
354+
ws_name = cp["workspace_name"] or cp["project_id"][:12]
355+
for session in cp["sessions"]:
356+
meta = session.get("meta", {})
357+
session_id = session["session_id"]
358+
created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000)
359+
session_name = meta.get("name") or f"Session {session_id[:8]}"
359360

360-
try:
361-
messages = traverse_blobs(session["db_path"])
362-
except Exception:
363-
continue
361+
try:
362+
messages = traverse_blobs(session["db_path"])
363+
except Exception:
364+
continue
364365

365-
bubbles = messages_to_bubbles(messages, created_ms)
366-
if not bubbles:
367-
continue
366+
bubbles = messages_to_bubbles(messages, created_ms)
367+
if not bubbles:
368+
continue
368369

369-
# Derive title
370-
title = session_name
371-
if not title or title.startswith("New Agent"):
372-
for b in bubbles:
373-
if b["type"] == "user" and b.get("text"):
374-
first_lines = [l for l in b["text"].split("\n") if l.strip()]
375-
if first_lines:
376-
title = first_lines[0][:100]
377-
break
378-
379-
bubble_texts = [b["text"] for b in bubbles if b.get("text")]
380-
exclusion_text = _build_exclusion_searchable(
381-
project_name=ws_name,
382-
chat_title=title,
383-
content_parts=bubble_texts,
384-
)
385-
if is_excluded_by_rules(rules, exclusion_text):
386-
continue
370+
# Derive title
371+
title = session_name
372+
if not title or title.startswith("New Agent"):
373+
for b in bubbles:
374+
if b["type"] == "user" and b.get("text"):
375+
first_lines = [ln for ln in b["text"].split("\n") if ln.strip()]
376+
if first_lines:
377+
title = first_lines[0][:100]
378+
break
387379

388-
has_match = False
389-
matching_text = ""
390-
391-
if title and query_lower in title.lower():
392-
has_match = True
393-
matching_text = title
394-
395-
if not has_match:
396-
for text in bubble_texts:
397-
if text and query_lower in text.lower():
398-
has_match = True
399-
idx = text.lower().find(query_lower)
400-
start = max(0, idx - 80)
401-
end = min(len(text), idx + len(query) + 120)
402-
matching_text = (
403-
("..." if start > 0 else "")
404-
+ text[start:end]
405-
+ ("..." if end < len(text) else "")
406-
)
407-
break
408-
409-
if has_match:
410-
results.append({
411-
"workspaceId": f"cli:{cp['project_id']}",
412-
"workspaceFolder": cp.get("workspace_path"),
413-
"chatId": session_id,
414-
"chatTitle": title,
415-
"timestamp": created_ms,
416-
"matchingText": matching_text,
417-
"type": "cli_agent",
418-
"source": "cli",
419-
})
420-
except Exception as e:
421-
print(f"Error searching CLI sessions: {e}")
380+
bubble_texts = [b["text"] for b in bubbles if b.get("text")]
381+
exclusion_text = _build_exclusion_searchable(
382+
project_name=ws_name,
383+
chat_title=title,
384+
content_parts=bubble_texts,
385+
)
386+
if is_excluded_by_rules(rules, exclusion_text):
387+
continue
388+
389+
has_match = False
390+
matching_text = ""
391+
392+
if title and query_lower in title.lower():
393+
has_match = True
394+
matching_text = title
395+
396+
if not has_match:
397+
for text in bubble_texts:
398+
if text and query_lower in text.lower():
399+
has_match = True
400+
idx = text.lower().find(query_lower)
401+
start = max(0, idx - 80)
402+
end = min(len(text), idx + len(query) + 120)
403+
matching_text = (
404+
("..." if start > 0 else "")
405+
+ text[start:end]
406+
+ ("..." if end < len(text) else "")
407+
)
408+
break
409+
410+
if has_match:
411+
results.append({
412+
"workspaceId": f"cli:{cp['project_id']}",
413+
"workspaceFolder": cp.get("workspace_path"),
414+
"chatId": session_id,
415+
"chatTitle": title,
416+
"timestamp": created_ms,
417+
"matchingText": matching_text,
418+
"type": "cli_agent",
419+
"source": "cli",
420+
})
421+
except Exception as e:
422+
print(f"Error searching CLI sessions: {e}")
422423

423424
# Sort by timestamp descending
424425
def _ts(r):

scripts/export.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -840,7 +840,15 @@ def assign_workspace(cd, cid):
840840
created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000)
841841
session_name = meta.get("name") or f"Session {session_id[:8]}"
842842

843-
if since == "last" and created_ms <= last_export:
843+
# Use the store.db mtime as a proxy for "last updated" — createdAt
844+
# is immutable and would cause sessions with new turns to be skipped.
845+
try:
846+
db_mtime_ms = int(os.path.getmtime(session["db_path"]) * 1000)
847+
except OSError:
848+
db_mtime_ms = created_ms
849+
updated_ms = max(created_ms, db_mtime_ms)
850+
851+
if since == "last" and updated_ms <= last_export:
844852
continue
845853

846854
try:
@@ -866,10 +874,15 @@ def assign_workspace(cd, cid):
866874
break
867875

868876
bubble_texts = [b["text"] for b in bubbles if b.get("text")]
877+
tool_call_texts = [
878+
tc.get("input", "") or tc.get("summary", "")
879+
for b in bubbles
880+
for tc in (b.get("metadata") or {}).get("toolCalls") or []
881+
]
869882
searchable = build_searchable_text(
870883
project_name=ws_name,
871884
chat_title=title,
872-
chat_content_snippet="\n\n".join(bubble_texts[:5]),
885+
chat_content_snippet="\n\n".join(bubble_texts + tool_call_texts),
873886
)
874887
if is_excluded_by_rules(exclusion_rules, searchable):
875888
continue
@@ -890,27 +903,27 @@ def assign_workspace(cd, cid):
890903
rel_dir = os.path.join(today, ws_slug_cli, "cli")
891904
out_path = os.path.join(out_dir, rel_dir, filename)
892905

893-
# Frontmatter
906+
# Frontmatter — free-form scalars use json.dumps() for safe YAML quoting.
894907
fm_lines = ["---"]
895-
fm_lines.append(f"log_id: {session_id}")
896-
fm_lines.append(f"log_type: cli_agent")
897-
fm_lines.append(f'title: "{title.replace(chr(34), chr(92)+chr(34))}"')
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)}")
898911
fm_lines.append(f"created_at: {datetime.fromtimestamp(created_ms / 1000).isoformat()}")
899912
fm_lines.append(f"workspace: {ws_slug_cli}")
900-
fm_lines.append(f'workspace_name: "{ws_name}"')
913+
fm_lines.append(f"workspace_name: {json.dumps(ws_name, ensure_ascii=False)}")
901914
if cp.get("workspace_path"):
902-
fm_lines.append(f"workspace_path: {cp['workspace_path']}")
903-
fm_lines.append(f"project_id: {cp['project_id']}")
904-
fm_lines.append(f"session_id: {session_id}")
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)}")
905918
if meta.get("mode"):
906-
fm_lines.append(f"mode: {meta['mode']}")
919+
fm_lines.append(f"mode: {json.dumps(meta['mode'], ensure_ascii=False)}")
907920
fm_lines.append(f"message_count: {len(bubbles)}")
908921
if total_tool_calls:
909922
fm_lines.append(f"total_tool_calls: {total_tool_calls}")
910923
if tool_breakdown:
911924
fm_lines.append("tool_call_breakdown:")
912925
for tn, cnt in sorted(tool_breakdown.items(), key=lambda x: -x[1]):
913-
fm_lines.append(f" {tn}: {cnt}")
926+
fm_lines.append(f" {json.dumps(tn, ensure_ascii=False)}: {cnt}")
914927
fm_lines.append("---")
915928
fm_str = "\n".join(fm_lines) + "\n\n"
916929

@@ -947,7 +960,7 @@ def assign_workspace(cd, cid):
947960
"rel_path": rel_path,
948961
"content": md,
949962
"out_path": out_path,
950-
"updatedAt": created_ms,
963+
"updatedAt": updated_ms,
951964
})
952965
count += 1
953966

tests/test_cli_chat_reader.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -359,23 +359,29 @@ def test_single_json_message(self):
359359
result = traverse_blobs(path)
360360
self.assertEqual(result, [msg])
361361

362-
def test_chain_to_json_blobs(self):
363-
"""Chain blob references two JSON message blobs."""
364-
root_id = "0" * 64
365-
msg1_id = "1" * 64
366-
msg2_id = "2" * 64
362+
def test_chain_preserves_chronological_order(self):
363+
"""Linked-list chain (newest root -> prev node -> oldest msg) must produce oldest-first output.
364+
365+
Mirrors the real CLI storage layout: latestRootBlobId is newest, and
366+
each chain node points to an older node/message.
367+
"""
368+
root_id = "0" * 64 # latest chain node (newest end)
369+
prev_id = "f" * 64 # older chain node
370+
msg1_id = "1" * 64 # oldest message (user)
371+
msg2_id = "2" * 64 # newest message (assistant)
367372
msg1 = {"role": "user", "content": "first"}
368373
msg2 = {"role": "assistant", "content": "second"}
369374
path = self._db("chain.db")
370375
_build_store_db(
371376
path,
372377
{"latestRootBlobId": root_id},
373378
{msg1_id: msg1, msg2_id: msg2},
374-
{root_id: [msg1_id, msg2_id]},
379+
# root (latest) references the newest message and a pointer to the older node;
380+
# prev node references the oldest message.
381+
{root_id: [msg2_id, prev_id], prev_id: [msg1_id]},
375382
)
376383
result = traverse_blobs(path)
377-
self.assertIn(msg1, result)
378-
self.assertIn(msg2, result)
384+
self.assertEqual(result, [msg1, msg2])
379385

380386
def test_no_cycle_in_traversal(self):
381387
"""Cyclic references must not loop forever."""

tests/test_cursor_md_exporter.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ def _db_path(self, name: str = "store.db") -> str:
5050
def _simple_session(self, name: str = "My Session", mode: str = "agent") -> tuple[str, dict]:
5151
"""Build a store.db with one user message and one assistant reply.
5252
53-
The root is a chain blob pointing to both message blobs so BFS reaches both.
53+
Models the real CLI linked-list layout: root (latest chain node) references
54+
the newest message (assistant) and a pointer to an older chain node which
55+
holds the older message (user). After traverse_blobs() reverses, callers
56+
receive [user, assistant] in chronological order.
5457
"""
55-
root_id = "0" * 64
58+
root_id = "0" * 64 # latest chain node
59+
prev_id = "f" * 64 # older chain node
5660
blob_user = "a" * 64
5761
blob_asst = "b" * 64
5862
meta = {
@@ -67,9 +71,12 @@ def _simple_session(self, name: str = "My Session", mode: str = "agent") -> tupl
6771
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
6872
conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)")
6973
conn.execute("INSERT INTO meta VALUES ('0', ?)", (json.dumps(meta).encode("utf-8").hex(),))
70-
# Chain blob pointing to both message blobs
71-
chain_data = b"\x0a\x20" + bytes.fromhex(blob_user) + b"\x0a\x20" + bytes.fromhex(blob_asst)
72-
conn.execute("INSERT INTO blobs VALUES (?, ?)", (root_id, chain_data))
74+
# root -> [newest msg (asst), prev chain node]
75+
root_chain = b"\x0a\x20" + bytes.fromhex(blob_asst) + b"\x0a\x20" + bytes.fromhex(prev_id)
76+
conn.execute("INSERT INTO blobs VALUES (?, ?)", (root_id, root_chain))
77+
# prev chain node -> [oldest msg (user)]
78+
prev_chain = b"\x0a\x20" + bytes.fromhex(blob_user)
79+
conn.execute("INSERT INTO blobs VALUES (?, ?)", (prev_id, prev_chain))
7380
conn.execute("INSERT INTO blobs VALUES (?, ?)", (
7481
blob_user, json.dumps({"role": "user", "content": "<user_query>Write a sort function</user_query>"}).encode("utf-8")
7582
))
@@ -94,7 +101,7 @@ def test_contains_yaml_frontmatter(self):
94101
def test_frontmatter_includes_session_id(self):
95102
db_path, _ = self._simple_session()
96103
result = cursor_cli_session_to_markdown(db_path)
97-
self.assertIn("log_id: test-agent-id", result)
104+
self.assertIn('log_id: "test-agent-id"', result)
98105

99106
def test_frontmatter_includes_log_type(self):
100107
db_path, _ = self._simple_session()
@@ -104,7 +111,7 @@ def test_frontmatter_includes_log_type(self):
104111
def test_frontmatter_includes_mode(self):
105112
db_path, _ = self._simple_session(mode="agent")
106113
result = cursor_cli_session_to_markdown(db_path)
107-
self.assertIn("mode: agent", result)
114+
self.assertIn('mode: "agent"', result)
108115

109116
def test_frontmatter_title_from_session_name(self):
110117
db_path, _ = self._simple_session(name="My Custom Title")
@@ -183,7 +190,7 @@ def test_tool_call_count_in_frontmatter(self):
183190
result = cursor_cli_session_to_markdown(db_path)
184191
self.assertIn("total_tool_calls: 2", result)
185192
self.assertIn("tool_call_breakdown:", result)
186-
self.assertIn("Read: 2", result)
193+
self.assertIn('"Read": 2', result)
187194

188195
def test_with_session_meta_kwarg(self):
189196
"""session_meta kwarg skips reading from the database."""
@@ -205,7 +212,7 @@ def test_with_session_meta_kwarg(self):
205212
"createdAt": 1_700_000_000_000,
206213
}
207214
result = cursor_cli_session_to_markdown(db_path, session_meta=provided_meta)
208-
self.assertIn("mode: custom", result)
215+
self.assertIn('mode: "custom"', result)
209216
self.assertIn("Provided Meta Session", result)
210217

211218
def test_empty_session_produces_valid_markdown(self):
@@ -231,8 +238,8 @@ def test_title_quoting_with_special_chars(self):
231238
db_path = self._db_path()
232239
_build_store_db(db_path, meta, json_blobs)
233240
result = cursor_cli_session_to_markdown(db_path)
234-
# Frontmatter title line must be valid YAML (escaped quotes)
235-
self.assertIn('title: "', result)
241+
# Frontmatter title must have embedded quotes escaped as \"
242+
self.assertIn('title: "He said \\"hello\\""', result)
236243

237244

238245
if __name__ == "__main__":

utils/cli_chat_reader.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,16 @@ def traverse_blobs(db_path: str) -> list[dict]:
116116
finally:
117117
conn.close()
118118

119-
# BFS from root
119+
# BFS from root (newest-first by nature of the linked-list structure);
120+
# reverse at the end to restore chronological (oldest→newest) order.
121+
from collections import deque
122+
120123
visited: set[str] = set()
121-
queue: list[str] = [root_id]
124+
queue: deque[str] = deque([root_id])
122125
messages: list[dict] = []
123126

124127
while queue:
125-
bid = queue.pop(0)
128+
bid = queue.popleft()
126129
if bid in visited:
127130
continue
128131
visited.add(bid)
@@ -134,6 +137,7 @@ def traverse_blobs(db_path: str) -> list[dict]:
134137
if ref not in visited:
135138
queue.append(ref)
136139

140+
messages.reverse()
137141
return messages
138142

139143

0 commit comments

Comments
 (0)