Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ jobs:
browser-xss:
name: Browser XSS (Playwright)
needs: [lockfile]
runs-on: ubuntu-latest
# ubuntu-latest is 26.04. Playwright 1.50.0 refuses Chromium there.
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Cursor 3 conversation index**: export, search, and workspace tabs read
`composerConvHeaders` when `fullConversationHeadersOnly` is empty, and keep
bubble ids that contain colons (`{uuid}/s:0`).

## [0.2.1] - 2026-07-30

### Added
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ requires-python = ">=3.10"
dependencies = [
"flask>=3.0,<4",
"fpdf2>=2.7,<3",
# fonttools 4.66+ requires Python >=3.11. The single lock must still
# install on the 3.10 matrix jobs.
"fonttools>=4.34,<4.66",
# Security floor: fpdf2 allows Pillow>=8.3.2 (no upper cap); pin 12.x to avoid
# known high-severity CVEs in Pillow 10.x (e.g. CVE-2024-28219 and later advisories).
"pillow>=12.2.0,<13",
Expand Down
10 changes: 5 additions & 5 deletions requirements-lock.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
# Run periodically (e.g. via the "Update dependency lock file" CI workflow) to pick up
# upstream patch / security releases within the bounded ranges in requirements.txt.
blinker==1.9.0 # via flask
click==8.4.2 # via flask
click==8.5.0 # via flask
defusedxml==0.7.1 # via fpdf2
flask==3.1.3 # via -r requirements.txt
fonttools==4.63.0 # via fpdf2
fpdf2==2.8.8 # via -r requirements.txt
fonttools==4.65.0 # via -r requirements.txt, fpdf2
fpdf2==2.8.9 # via -r requirements.txt
itsdangerous==2.2.0 # via flask
jinja2==3.1.6 # via flask
markupsafe==3.0.3 # via flask, jinja2, werkzeug
markupsafe==3.0.4 # via flask, jinja2, werkzeug
pillow==12.3.0 # via -r requirements.txt, fpdf2
werkzeug==3.1.8 # via flask
werkzeug==3.1.9 # via flask
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
# pip install -e ".[desktop]" (+ pywebview for the GUI launcher)
flask>=3.0,<4
fpdf2>=2.7,<3
# fonttools 4.66+ requires Python >=3.11. The single lock must still
# install on the 3.10 matrix jobs.
fonttools>=4.34,<4.66
pillow>=12.2.0,<13
# pywebview is desktop-only — install with: pip install -e ".[desktop]"
7 changes: 6 additions & 1 deletion services/export_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
import os
import sqlite3
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Literal

Expand Down Expand Up @@ -35,6 +35,7 @@
messages_to_bubbles,
traverse_blobs,
)
from utils.conversation_index import composer_data_with_headers, load_conv_header_map
from utils.cursor_md_exporter import (
cursor_cli_session_to_markdown,
cursor_ide_chat_to_markdown,
Expand Down Expand Up @@ -97,6 +98,7 @@ class GlobalDbExportData:
code_block_diff_map: dict[str, list[Any]]
ide_composer_rows: list[sqlite3.Row]
invalid_workspace_aliases: dict[str, str]
conv_header_map: dict[str, list[dict[str, Any]]] = field(default_factory=dict)


def json_dump_safe(value: object) -> str:
Expand Down Expand Up @@ -186,6 +188,7 @@ def load_global_db_export_data(
project_layouts_map = enriched.project_layouts_map
bubble_map = enriched.bubble_map
code_block_diff_map = load_code_block_diff_map(global_db)
conv_header_map = load_conv_header_map(global_db)
ide_composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)

invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
Expand All @@ -203,6 +206,7 @@ def load_global_db_export_data(
code_block_diff_map=code_block_diff_map,
ide_composer_rows=ide_composer_rows,
invalid_workspace_aliases=invalid_workspace_aliases,
conv_header_map=conv_header_map,
)


Expand Down Expand Up @@ -245,6 +249,7 @@ def _collect_ide_export_entries(
)
continue

cd = composer_data_with_headers(cd, composer_id, db_data.conv_header_map)
headers = cd.get("fullConversationHeadersOnly") or []
if not isinstance(headers, list) or not headers:
continue
Expand Down
13 changes: 11 additions & 2 deletions services/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
)
from services.workspace_resolver import infer_invalid_workspace_aliases
from utils.cli_chat_reader import list_cli_projects, messages_to_bubbles, traverse_blobs
from utils.conversation_index import composer_data_with_headers, load_conv_header_map
from utils.exclusion_rules import RuleTokens, build_searchable_text, is_excluded_by_rules
from utils.text_extract import extract_text_from_bubble
from utils.path_helpers import (
Expand Down Expand Up @@ -506,6 +507,11 @@ def _search_global_storage_via_index(
workspace_path, workspace_entries, rules,
)

with open_global_db(workspace_path) as (header_db, _):
conv_header_map = (
load_conv_header_map(header_db) if header_db is not None else {}
)

composers_in_window = query_composer_rows_in_window(since_ms)
if since_ms is not None and not composers_in_window:
return results
Expand Down Expand Up @@ -551,8 +557,9 @@ def _search_global_storage_via_index(
continue

try:
cd = composer_data_with_headers(cd, composer_id, conv_header_map)
headers = cd.get("fullConversationHeadersOnly") or []
if not headers:
if not isinstance(headers, list) or not headers:
continue

title = composer_row["title"] or cd.get("name") or ""
Expand Down Expand Up @@ -677,6 +684,7 @@ def _search_global_storage_live_scan(
return results

composer_rows = conn.execute(COMPOSER_ROWS_WITH_HEADERS_SQL).fetchall()
conv_header_map = load_conv_header_map(conn)

window_composer_ids: set[str] | None = None
if since_ms is not None:
Expand Down Expand Up @@ -726,8 +734,9 @@ def _search_global_storage_live_scan(
continue

try:
cd = composer_data_with_headers(cd, composer_id, conv_header_map)
headers = cd.get("fullConversationHeadersOnly") or []
if not headers:
if not isinstance(headers, list) or not headers:
continue

title = cd.get("name") or ""
Expand Down
47 changes: 36 additions & 11 deletions services/search_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@

_logger = logging.getLogger(__name__)

INDEX_VERSION = 1
# Bumped when the indexed composer set changes. Version 2 includes composers
# whose inline fullConversationHeadersOnly list is empty (Cursor 3.x stores
# the index in composerConvHeaders instead).
INDEX_VERSION = 2
SEARCH_INDEX_POINTER_FILE = CACHE_DIR / "search_index.active"
# Legacy single-file path (pre-pointer builds); still honored when no pointer exists.
SEARCH_INDEX_FILE = CACHE_DIR / "search_index.sqlite"
Expand Down Expand Up @@ -224,7 +227,10 @@ def build_search_index(
*,
force: bool = False,
) -> bool:
"""Rebuild search index when fingerprint differs. Returns True if rebuilt."""
"""Rebuild search index when the fingerprint or stored version differs.

Returns True if rebuilt.
"""
if not index_search_enabled():
return False

Expand All @@ -237,10 +243,8 @@ def build_search_index(
active_path = _resolve_active_index_db_path()
if not force and active_path is not None:
with _index_db_conn(readonly=True) as existing:
if existing is not None:
stored = _read_stored_fingerprint(existing)
if stored is not None and _fingerprints_match(stored, fingerprint):
return False
if existing is not None and not _index_needs_rebuild(existing, fingerprint):
return False

CACHE_DIR.mkdir(parents=True, exist_ok=True)
new_path = CACHE_DIR / f"search_index.{uuid.uuid4().hex[:12]}.sqlite"
Expand Down Expand Up @@ -362,8 +366,7 @@ def ensure_search_index(workspace_path: str, rules: list[RuleTokens]) -> None:
if conn is None:
build_search_index(workspace_path, rules)
return
stored = _read_stored_fingerprint(conn)
if stored is None or not _fingerprints_match(stored, fingerprint):
if _index_needs_rebuild(conn, fingerprint):
build_search_index(workspace_path, rules)


Expand Down Expand Up @@ -541,13 +544,35 @@ def query_composer_rows_in_window(
return {row["composer_id"]: row for row in rows}


def _index_needs_rebuild(
conn: sqlite3.Connection,
fingerprint: dict[str, Any],
) -> bool:
"""True when the stored fingerprint or index version is not current."""
stored = _read_stored_fingerprint(conn)
if stored is None or not _fingerprints_match(stored, fingerprint):
return True
return _stored_index_version(conn) != str(INDEX_VERSION)


def _stored_index_version(conn: sqlite3.Connection) -> str | None:
try:
row = conn.execute(
"SELECT value FROM index_meta WHERE key = 'version'"
).fetchone()
except sqlite3.Error:
return None
if not row or row[0] is None:
return None
return str(row[0])


def index_is_usable(workspace_path: str, rules: list[RuleTokens]) -> bool:
"""True when the on-disk index matches the current Cursor storage fingerprint."""
"""True when the on-disk index matches the storage fingerprint and INDEX_VERSION."""
if not index_search_enabled() or _resolve_active_index_db_path() is None:
return False
fingerprint = _storage_fingerprint(workspace_path, rules)
with _index_db_conn(readonly=True) as conn:
if conn is None:
return False
stored = _read_stored_fingerprint(conn)
return stored is not None and _fingerprints_match(stored, fingerprint)
return not _index_needs_rebuild(conn, fingerprint)
6 changes: 6 additions & 0 deletions services/workspace_composer_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any

from models import Bubble, Composer, ParseWarningCollector, SchemaError
from utils.conversation_index import composer_data_with_headers
from utils.exclusion_rules import RuleTokens, build_searchable_text, is_excluded_by_rules
from services.workspace_resolver import determine_project_for_conversation

Expand All @@ -23,11 +24,15 @@ def parse_composer_data_row(
raw_value: object | None,
*,
parse_warnings: ParseWarningCollector,
conv_header_map: Mapping[str, list[dict[str, Any]]] | None = None,
) -> Composer | None:
"""Parse a ``composerData:*`` KV row into a :class:`Composer`.

Returns ``None`` for null/placeholder payloads (e.g. ``empty-state-draft``)
without emitting decode warnings. Logs and records skips for malformed JSON.
When ``fullConversationHeadersOnly`` is empty, headers are taken from
*conv_header_map* (``composerConvHeaders`` chunks). A composer with no
resolved headers is omitted.
"""
if not row_key.startswith("composerData:"):
return None
Expand All @@ -53,6 +58,7 @@ def parse_composer_data_row(
if not isinstance(cd, dict):
parse_warnings.record_composer_skipped()
return None
cd = composer_data_with_headers(cd, composer_id, conv_header_map or {})
try:
composer = Composer.from_dict(cd, composer_id=composer_id)
except SchemaError as e:
Expand Down
19 changes: 12 additions & 7 deletions services/workspace_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
_logger = logging.getLogger(__name__)

from models import Bubble, ParseWarningCollector, SchemaError
from utils.conversation_index import bubble_id_from_kv_key
from utils.path_helpers import get_workspace_folder_paths
from utils.workspace_descriptor import read_json_file

Expand Down Expand Up @@ -44,11 +45,14 @@ def _parse_bubble_kv_row(
*,
parse_warnings: ParseWarningCollector | None = None,
) -> tuple[str, Bubble] | None:
"""Parse one ``bubbleId:…`` row; return ``(bubble_id, Bubble)`` or skip."""
parts = row_key.split(":")
if len(parts) < 3:
"""Parse one ``bubbleId:…`` row; return ``(bubble_id, Bubble)`` or skip.

Bubble ids may contain colons (``{uuid}/s:0``). The id is the key tail
after ``bubbleId:{composerId}:``.
"""
bid = bubble_id_from_kv_key(row_key)
if not bid:
return None
bid = parts[2]
try:
parsed = json.loads(row_value)
bubble = Bubble.from_dict(parsed, bubble_id=bid)
Expand Down Expand Up @@ -344,13 +348,14 @@ def collect_invalid_workspace_ids(workspace_entries: list[dict[str, Any]]) -> se
return invalid


# Composers that have at least one header entry (list/summary paths).
# Composers that store a conversation index field. Cursor 3.x leaves
# fullConversationHeadersOnly as [] and writes the ordered headers to
# composerConvHeaders:{id}:{chunk}. Callers resolve that fallback and skip
# composers whose resolved header list is still empty (drafts).
COMPOSER_ROWS_WITH_HEADERS_SQL = (
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
" AND LENGTH(value) > 10"
" AND value LIKE '%fullConversationHeadersOnly%'"
" AND value NOT LIKE '%fullConversationHeadersOnly\":[]%'"
" AND value NOT LIKE '%fullConversationHeadersOnly\": []%'"
)


Expand Down
7 changes: 6 additions & 1 deletion services/workspace_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

_logger = logging.getLogger(__name__)

from utils.conversation_index import load_conv_header_map
from utils.cli_chat_reader import list_cli_projects
from utils.exclusion_rules import RuleTokens, build_searchable_text, is_excluded_by_rules
from utils.path_helpers import (
Expand Down Expand Up @@ -120,6 +121,7 @@ def _build_workspace_projects_uncached(
if global_db:
try:
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
conv_header_map = load_conv_header_map(global_db)
project_layouts_map = load_project_layouts_map(global_db)

bubble_map: dict[str, Bubble] = {}
Expand All @@ -134,7 +136,10 @@ def _build_workspace_projects_uncached(

for row in composer_rows:
composer = parse_composer_data_row(
row["key"], row["value"], parse_warnings=parse_warnings,
row["key"],
row["value"],
parse_warnings=parse_warnings,
conv_header_map=conv_header_map,
)
if composer is None:
continue
Expand Down
Loading
Loading