Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
33 changes: 27 additions & 6 deletions api/composers.py
Comment thread
timon0305 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from utils.workspace_path import resolve_workspace_path
from utils.path_helpers import to_epoch_ms
from models import SchemaError, WorkspaceLocalComposer

bp = Blueprint("composers", __name__)

Expand Down Expand Up @@ -54,13 +55,33 @@ def list_composers():

if row and row[0]:
data = json.loads(row[0])
if not isinstance(data, dict):
raise SchemaError(
"WorkspaceComposers",
"composer.composerData",
hint=f"expected object, got {type(data).__name__}",
)
if "allComposers" not in data:
raise SchemaError("WorkspaceComposers", "allComposers")
all_composers = data.get("allComposers")
if isinstance(all_composers, list):
for c in all_composers:
c["conversation"] = c.get("conversation") or []
c["workspaceId"] = name
c["workspaceFolder"] = workspace_folder
composers.append(c)
if not isinstance(all_composers, list):
raise SchemaError(
"WorkspaceComposers",
Comment thread
timon0305 marked this conversation as resolved.
"allComposers",
hint=f"expected list, got {type(all_composers).__name__}",
)
for c in all_composers:
try:
WorkspaceLocalComposer.from_dict(c)
except SchemaError as e:
print(f"Schema drift in {db_path}: {e}")
continue
c["conversation"] = c.get("conversation") or []
c["workspaceId"] = name
Comment thread
timon0305 marked this conversation as resolved.
c["workspaceFolder"] = workspace_folder
composers.append(c)
Comment thread
timon0305 marked this conversation as resolved.
Outdated
except SchemaError as e:
print(f"Schema drift in {db_path}: {e}")
except Exception:
pass
Comment thread
timon0305 marked this conversation as resolved.
Outdated

Expand Down
18 changes: 13 additions & 5 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from utils.path_helpers import normalize_file_path, get_workspace_folder_paths, to_epoch_ms
from utils.text_extract import extract_text_from_bubble
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
from models import Composer, SchemaError

bp = Blueprint("search", __name__)

Expand Down Expand Up @@ -161,17 +162,24 @@ def search():
for row in composer_rows:
composer_id = row["key"].split(":")[1]
try:
cd = json.loads(row["value"])
headers = cd.get("fullConversationHeadersOnly") or []
composer = Composer.from_dict(json.loads(row["value"]), composer_id=composer_id)
except SchemaError as e:
print(f"Schema drift in composer {composer_id}: {e}")
continue
except (json.JSONDecodeError, TypeError, ValueError):
continue
try:
cd = composer.raw
headers = composer.full_conversation_headers_only
if not headers:
continue

title = cd.get("name") or ""
title = composer.name or ""
ws_id = composer_id_to_ws.get(composer_id, "global")
ws_name = ws_id_to_name.get(ws_id)
project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id)

model_config = cd.get("modelConfig") or {}
model_config = composer.model_config
model_name = model_config.get("modelName")
model_names = [model_name] if model_name and model_name != "default" else None

Expand Down Expand Up @@ -243,7 +251,7 @@ def search():
"workspaceFolder": ws_name,
"chatId": composer_id,
"chatTitle": title,
"timestamp": to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(cd.get("createdAt")) or int(datetime.now().timestamp() * 1000),
"timestamp": to_epoch_ms(composer.last_updated_at) or to_epoch_ms(composer.created_at) or int(datetime.now().timestamp() * 1000),
"matchingText": matching_text,
"type": "composer",
})
Expand Down
19 changes: 13 additions & 6 deletions api/workspaces.py
Comment thread
timon0305 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from utils.text_extract import extract_text_from_bubble, format_tool_action
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
from models import Composer, SchemaError

bp = Blueprint("workspaces", __name__)

Expand Down Expand Up @@ -600,9 +601,15 @@ def list_workspaces():
for row in composer_rows:
cid = row["key"].split(":")[1]
try:
cd = json.loads(row["value"])
composer = Composer.from_dict(json.loads(row["value"]), composer_id=cid)
except SchemaError as e:
print(f"Schema drift in composer {cid}: {e}")
continue
Comment thread
timon0305 marked this conversation as resolved.
except (json.JSONDecodeError, TypeError, ValueError):
continue
try:
pid = _determine_project_for_conversation(
cd, cid, project_layouts_map,
composer.raw, cid, project_layouts_map,
project_name_map, workspace_path_map,
workspace_entries, bubble_map, composer_id_to_ws, invalid_workspace_ids
)
Expand All @@ -611,16 +618,16 @@ def list_workspaces():
pid = invalid_workspace_aliases.get(mapped_ws)
assigned = pid if pid else "global"

headers = cd.get("fullConversationHeadersOnly") or []
headers = composer.full_conversation_headers_only
has_bubbles = any(bubble_map.get(h.get("bubbleId")) for h in headers)
if not has_bubbles:
continue

conversation_map.setdefault(assigned, []).append({
"composerId": cid,
"name": cd.get("name") or f"Conversation {cid[:8]}",
"lastUpdatedAt": to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(cd.get("createdAt")) or 0,
"createdAt": to_epoch_ms(cd.get("createdAt")) or 0,
"name": composer.name or f"Conversation {cid[:8]}",
"lastUpdatedAt": to_epoch_ms(composer.last_updated_at) or to_epoch_ms(composer.created_at) or 0,
"createdAt": to_epoch_ms(composer.created_at) or 0,
})
except Exception:
pass
Expand Down
25 changes: 25 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Typed domain models for Cursor schema (closes #24).

Cursor's on-disk JSON shapes are not versioned, so silent renames of fields
like ``composerData`` or ``latestRootBlobId`` would otherwise pass through
``dict.get(...)`` with a fallback default and produce empty conversations
with no error raised. The models here add a schema-validation boundary at
database read sites: ``from_dict`` classmethods raise ``SchemaError`` when
critical fields are missing, so drift becomes loud instead of silent.
"""

from models.cli_session import CliSessionMeta
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
from models.errors import SchemaError
from models.export import ExportEntry
from models.workspace import Workspace

__all__ = [
"Bubble",
"CliSessionMeta",
"Composer",
"ExportEntry",
"SchemaError",
"Workspace",
"WorkspaceLocalComposer",
]
49 changes: 49 additions & 0 deletions models/cli_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""CliSessionMeta — typed model for the Cursor CLI ``meta`` blob."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from models.errors import SchemaError


@dataclass(frozen=True)
class CliSessionMeta:
"""The ``meta`` blob at the head of a Cursor CLI ``store.db`` blob graph.

``latestRootBlobId`` is the entry point for the conversation reconstruction
BFS in ``utils/cli_chat_reader.traverse_blobs``; without it, the entire
conversation is unreachable. ``createdAt`` is documented as part of the
meta-blob schema (see ``utils/cli_chat_reader`` module docstring) and is
captured here, but it is not gated on — only ``latestRootBlobId`` is the
hard requirement, since that is the only field whose absence prevents
conversation reconstruction.
"""

latest_root_blob_id: str
created_at: Any = None
raw: dict[str, Any] = field(default_factory=dict)

@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "CliSessionMeta":
if not isinstance(raw, dict):
raise SchemaError(
"CliSessionMeta",
"meta",
hint=f"expected object, got {type(raw).__name__}",
)
latest = raw.get("latestRootBlobId")
if not latest:
raise SchemaError("CliSessionMeta", "latestRootBlobId")
if not isinstance(latest, str):
Comment thread
timon0305 marked this conversation as resolved.
raise SchemaError(
"CliSessionMeta",
"latestRootBlobId",
hint=f"expected str, got {type(latest).__name__}",
)
return cls(
latest_root_blob_id=latest,
created_at=raw.get("createdAt"),
raw=raw,
)
136 changes: 136 additions & 0 deletions models/conversation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Composer (conversation) and Bubble (message) typed models."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from models.errors import SchemaError


@dataclass(frozen=True)
class Composer:
"""A Cursor conversation (a.k.a. "composer") row.

Required fields per the schema-validation contract (issue #24):
- ``fullConversationHeadersOnly`` — without this, a composer cannot be
rendered (no message order is recoverable).
- ``createdAt`` — Cursor writes this on every composer (verified
17/17 against a live workspaceStorage). A missing value is the
kind of drift this layer exists to surface.

The composer ID is intentionally passed in as a constructor argument
rather than read from ``raw`` because Cursor stores it in the row key
(``composerData:<id>``) rather than in the JSON value.
"""

composer_id: str
full_conversation_headers_only: list[dict[str, Any]]
created_at: Any
name: str | None = None
last_updated_at: Any = None
model_config: dict[str, Any] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)

@classmethod
def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer":
if not isinstance(raw, dict):
raise SchemaError(
"Composer",
"composerData",
hint=f"expected object, got {type(raw).__name__}",
)
if not composer_id:
raise SchemaError("Composer", "composerId", hint="empty composer ID")
Comment thread
timon0305 marked this conversation as resolved.
Outdated
if "fullConversationHeadersOnly" not in raw:
raise SchemaError("Composer", "fullConversationHeadersOnly")
if "createdAt" not in raw:
raise SchemaError("Composer", "createdAt")
Comment thread
timon0305 marked this conversation as resolved.

Comment thread
timon0305 marked this conversation as resolved.
headers = raw.get("fullConversationHeadersOnly")
if not isinstance(headers, list):
raise SchemaError(
"Composer",
"fullConversationHeadersOnly",
hint=f"expected list, got {type(headers).__name__}",
)

model_config = raw.get("modelConfig") or {}
if not isinstance(model_config, dict):
model_config = {}

return cls(
composer_id=composer_id,
full_conversation_headers_only=headers,
created_at=raw.get("createdAt"),
name=raw.get("name"),
last_updated_at=raw.get("lastUpdatedAt"),
model_config=model_config,
raw=raw,
)


@dataclass(frozen=True)
class WorkspaceLocalComposer:
"""A composer entry from ``composer.composerData`` ItemTable rows.

These are summary records that live in each per-workspace ``state.vscdb``.
They share ``composerId`` and ``lastUpdatedAt`` with the global composer
schema, but they do **not** carry ``fullConversationHeadersOnly`` or
``createdAt`` — those only exist on the global ``cursorDiskKV`` rows that
``Composer.from_dict`` validates. Treating both shapes through the same
model would reject every workspace-local entry, so this slim model
exists to keep schema-drift detection at the boundary without conflating
the two storage paths.
"""

composer_id: str
last_updated_at: Any = None
raw: dict[str, Any] = field(default_factory=dict)

@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer":
if not isinstance(raw, dict):
raise SchemaError(
"WorkspaceLocalComposer",
"composer",
hint=f"expected object, got {type(raw).__name__}",
)
composer_id = raw.get("composerId")
if not isinstance(composer_id, str) or not composer_id:
raise SchemaError(
"WorkspaceLocalComposer",
"composerId",
hint=f"expected non-empty str, got {type(composer_id).__name__}",
)
return cls(
composer_id=composer_id,
last_updated_at=raw.get("lastUpdatedAt"),
raw=raw,
)


@dataclass(frozen=True)
class Bubble:
"""A single message bubble within a composer.

The bubble ID lives in the row key (``bubbleId:<composer_id>:<bubble_id>``)
rather than the JSON value, so it is passed in explicitly. The raw dict
is preserved to keep downstream rendering code (which still walks the
untyped shape) working without modification.
"""

bubble_id: str
raw: dict[str, Any] = field(default_factory=dict)

@classmethod
def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble":
if not isinstance(raw, dict):
raise SchemaError(
"Bubble",
"bubble",
hint=f"expected object, got {type(raw).__name__}",
)
if not bubble_id:
raise SchemaError("Bubble", "bubbleId", hint="empty bubble ID")
return cls(bubble_id=bubble_id, raw=raw)
22 changes: 22 additions & 0 deletions models/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Exception types for the typed-model schema-validation layer."""

from __future__ import annotations


class SchemaError(ValueError):
"""Raised when a required Cursor schema field is missing or malformed.

Inherits from ``ValueError`` so call sites that already catch generic
deserialisation errors (e.g. ``json.JSONDecodeError`` is a subclass of
``ValueError``) also catch schema drift without needing a separate
``except`` clause. New code should catch ``SchemaError`` explicitly.
"""

def __init__(self, model: str, field: str, *, hint: str | None = None) -> None:
self.model = model
self.field = field
self.hint = hint
message = f"{model}: missing required field '{field}'"
Comment thread
timon0305 marked this conversation as resolved.
Outdated
if hint:
message = f"{message} ({hint})"
super().__init__(message)
Loading
Loading