Skip to content

Commit ecc849c

Browse files
bradjin8cursoragent
andcommitted
fix: unify bubble KV loaders and converge DisplayBubble shapes
Route all bubbleId:* loads through workspace_db._parse_bubble_kv_row and load_bubble_map (including workspace tabs). Introduce DisplayBubble/BubbleMetadata and utils/display_bubble builders shared by tabs, CLI, and IDE markdown export. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cc24f11 commit ecc849c

19 files changed

Lines changed: 465 additions & 370 deletions

models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from models.bubble_display import BubbleMetadata, BubbleRole, DisplayBubble
12
from models.cli_session import CliSessionMeta
23
from models.conversation import Bubble, Composer, Conversation, WorkspaceLocalComposer
34
from models.errors import SchemaError
@@ -8,7 +9,10 @@
89

910
__all__ = [
1011
"Bubble",
12+
"BubbleMetadata",
13+
"BubbleRole",
1114
"CliSessionMeta",
15+
"DisplayBubble",
1216
"Composer",
1317
"Conversation",
1418
"ConversationSummary",

models/bubble_display.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Rendered bubble shapes for UI, CLI, and markdown export.
2+
3+
Storage/KV rows (``bubbleId:*`` in ``cursorDiskKV``) are validated as
4+
:class:`models.conversation.Bubble` at load time via
5+
:func:`services.workspace_db.load_bubble_map`. All user-facing paths emit
6+
:class:`DisplayBubble` with optional :class:`BubbleMetadata`.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any, Literal, TypedDict
12+
13+
BubbleRole = Literal["user", "ai"]
14+
15+
16+
class BubbleMetadata(TypedDict, total=False):
17+
"""Nested fields on a :class:`DisplayBubble` (tabs, CLI, export)."""
18+
19+
modelName: str
20+
inputTokens: int
21+
outputTokens: int
22+
cachedTokens: int
23+
toolResultsCount: int
24+
toolResults: list[Any]
25+
toolCalls: list[dict[str, Any]]
26+
thinking: str
27+
thinkingDurationMs: int | float
28+
contextWindowPercent: float
29+
contextTokensUsed: int
30+
contextTokenLimit: int
31+
contextPctRemaining: float
32+
responseTimeMs: int
33+
cost: float
34+
35+
36+
class _DisplayBubbleRequired(TypedDict):
37+
type: BubbleRole
38+
text: str
39+
timestamp: int
40+
41+
42+
class _DisplayBubbleOptional(TypedDict, total=False):
43+
metadata: BubbleMetadata
44+
45+
46+
class DisplayBubble(_DisplayBubbleRequired, _DisplayBubbleOptional):
47+
"""One message bubble in the browser UI or an exported Markdown document."""

models/conversation.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer":
177177

178178
@dataclass(frozen=True)
179179
class Bubble:
180-
"""One message in a composer; bubble_id comes from the row key, not the JSON value."""
180+
"""One message in a composer; bubble_id comes from the row key, not the JSON value.
181+
182+
Rendered for UI/export as :class:`models.bubble_display.DisplayBubble`.
183+
"""
181184

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

scripts/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
cursor_cli_session_to_markdown,
5353
cursor_ide_chat_to_markdown,
5454
)
55-
from models import ExportEntry, SchemaError # noqa: E402
55+
from models import Bubble, ExportEntry, SchemaError # noqa: E402
5656
from services.workspace_context import ( # noqa: E402
5757
enrich_workspace_context_from_global_db,
5858
resolve_workspace_context,
@@ -221,7 +221,7 @@ def main():
221221

222222
# ── Database reading via service layer ────────────────────────────────────
223223
project_layouts_map: dict = {}
224-
bubble_map: dict = {}
224+
bubble_map: dict[str, Bubble] = {}
225225
code_block_diff_map: dict = {}
226226
ide_composer_rows: list = []
227227
invalid_workspace_aliases: dict = {}

services/search.py

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@
3232
"search_global_storage",
3333
"search_legacy_workspaces",
3434
]
35-
from models import Bubble, Composer, ParseWarningCollector, SchemaError, SearchResult
35+
from models import Composer, ParseWarningCollector, SchemaError, SearchResult
3636
from services.workspace_db import (
3737
build_composer_id_to_workspace_id_cached,
3838
collect_workspace_entries,
39+
load_bubble_map,
3940
open_global_db,
4041
)
4142
from utils.cli_chat_reader import list_cli_projects, messages_to_bubbles, traverse_blobs
@@ -153,38 +154,6 @@ def _build_ws_id_to_name(
153154
return mapping
154155

155156

156-
def _build_search_bubble_map(
157-
global_db: sqlite3.Connection,
158-
parse_warnings: ParseWarningCollector,
159-
) -> dict[str, dict[str, Any]]:
160-
"""Load ``bubbleId:*`` rows from an open global DB connection.
161-
162-
Returns ``{bubble_id: {"text": str, "raw": dict}}``. Rows that fail
163-
schema validation or JSON decoding are skipped; the skip is recorded in
164-
*parse_warnings*.
165-
"""
166-
bubble_map: dict[str, dict[str, Any]] = {}
167-
for row in global_db.execute(
168-
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
169-
):
170-
parts = row["key"].split(":")
171-
if len(parts) < 3:
172-
continue
173-
bid = parts[2]
174-
try:
175-
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
176-
bubble_map[bid] = {"text": extract_text_from_bubble(bubble), "raw": bubble.raw}
177-
except SchemaError as exc:
178-
_logger.warning(
179-
"Schema drift in bubble %s: %s (%s)", bid, exc, type(exc).__name__
180-
)
181-
parse_warnings.record_bubble_skipped()
182-
except (json.JSONDecodeError, TypeError, ValueError) as exc:
183-
_logger.warning("Failed to decode Bubble from bubbleId:%s: %s", bid, exc)
184-
parse_warnings.record_bubble_skipped()
185-
return bubble_map
186-
187-
188157
# ---------------------------------------------------------------------------
189158
# Public: per-source search functions
190159
# ---------------------------------------------------------------------------
@@ -223,7 +192,7 @@ def search_global_storage(
223192
with open_global_db(workspace_path) as (conn, _db_path):
224193
if conn is None:
225194
return results
226-
bubble_map = _build_search_bubble_map(conn, parse_warnings)
195+
bubble_map = load_bubble_map(conn, parse_warnings=parse_warnings)
227196
composer_rows = conn.execute(
228197
"SELECT key, value FROM cursorDiskKV"
229198
" WHERE key LIKE 'composerData:%' AND LENGTH(value) > 10"
@@ -276,15 +245,13 @@ def search_global_storage(
276245
bid = header.get("bubbleId")
277246
if not bid:
278247
continue
279-
entry = bubble_map.get(bid)
280-
if not entry:
248+
bubble = bubble_map.get(bid)
249+
if bubble is None:
281250
continue
282-
text = entry.get("text") or ""
251+
text = extract_text_from_bubble(bubble)
283252
if text:
284253
bubble_texts.append(text)
285-
raw_bubble = entry.get("raw")
286-
if raw_bubble:
287-
bubble_meta.append(_json_dump_safe(raw_bubble))
254+
bubble_meta.append(_json_dump_safe(bubble.raw))
288255

289256
exclusion_text = _build_exclusion_searchable(
290257
project_name=project_name,

services/workspace_context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import dataclass, replace
77
from typing import Any
88

9+
from models import Bubble
910
from services.workspace_db import (
1011
build_composer_id_to_workspace_id,
1112
build_composer_id_to_workspace_id_cached,
@@ -30,7 +31,7 @@ class WorkspaceContext:
3031
project_name_to_workspace_id: dict[str, str]
3132
workspace_path_to_id: dict[str, str]
3233
project_layouts_map: dict[str, list[str]]
33-
bubble_map: dict[str, dict[str, Any]]
34+
bubble_map: dict[str, Bubble]
3435

3536

3637
def _entries(

services/workspace_db.py

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
_logger = logging.getLogger(__name__)
1313

14+
from models import Bubble, ParseWarningCollector, SchemaError
1415
from utils.path_helpers import get_workspace_folder_paths
1516
from utils.workspace_descriptor import read_json_file
1617

@@ -34,30 +35,63 @@ def safe_fetchall(
3435
return []
3536

3637

37-
def load_bubble_map(global_db: sqlite3.Connection) -> dict[str, dict[str, Any]]:
38-
"""Load all ``bubbleId:*`` KV entries into ``{bubble_id: bubble_dict}``.
38+
def _parse_bubble_kv_row(
39+
row_key: str,
40+
row_value: str | bytes,
41+
*,
42+
parse_warnings: ParseWarningCollector | None = None,
43+
) -> tuple[str, Bubble] | None:
44+
"""Parse one ``bubbleId:…`` row; return ``(bubble_id, Bubble)`` or skip."""
45+
parts = row_key.split(":")
46+
if len(parts) < 3:
47+
return None
48+
bid = parts[2]
49+
try:
50+
parsed = json.loads(row_value)
51+
bubble = Bubble.from_dict(parsed, bubble_id=bid)
52+
return bid, bubble
53+
except SchemaError as exc:
54+
_logger.warning(
55+
"Schema drift in bubble %s: %s (%s)", bid, exc, type(exc).__name__
56+
)
57+
if parse_warnings is not None:
58+
parse_warnings.record_bubble_skipped()
59+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
60+
if parse_warnings is not None:
61+
_logger.warning(
62+
"Failed to decode Bubble from %s: %s", row_key, exc
63+
)
64+
parse_warnings.record_bubble_skipped()
65+
else:
66+
_logger.debug("Skipping malformed bubbleId row %s: %s", row_key, exc)
67+
return None
3968

40-
Skips rows whose JSON value is not a dict; JSON parse errors are logged at
41-
DEBUG level so a single malformed row cannot block the rest.
69+
70+
def load_bubble_map(
71+
global_db: sqlite3.Connection,
72+
*,
73+
parse_warnings: ParseWarningCollector | None = None,
74+
) -> dict[str, Bubble]:
75+
"""Load all ``bubbleId:*`` KV entries into ``{bubble_id: Bubble}``.
76+
77+
Uses the same :meth:`Bubble.from_dict` validation as search and tabs.
78+
When *parse_warnings* is set, skipped rows are recorded for the API.
4279
"""
43-
bubble_map: dict[str, dict[str, Any]] = {}
80+
bubble_map: dict[str, Bubble] = {}
4481
try:
4582
rows = global_db.execute(
46-
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
83+
"SELECT key, value FROM cursorDiskKV"
84+
" WHERE key LIKE 'bubbleId:%' AND value IS NOT NULL"
4785
).fetchall()
4886
except sqlite3.Error:
4987
return bubble_map
5088
for row in rows:
51-
parts = row["key"].split(":")
52-
if len(parts) < 3:
53-
continue
54-
bid = parts[2]
55-
try:
56-
b = json.loads(row["value"])
57-
if isinstance(b, dict):
58-
bubble_map[bid] = b
59-
except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e:
60-
_logger.debug("Skipping malformed bubbleId row %s: %s", row["key"], e)
89+
parsed = _parse_bubble_kv_row(
90+
row["key"], row["value"], parse_warnings=parse_warnings
91+
)
92+
if parsed is not None:
93+
bid, bubble = parsed
94+
bubble_map[bid] = bubble
6195
return bubble_map
6296

6397

@@ -163,14 +197,17 @@ def load_code_block_diff_map(global_db: sqlite3.Connection) -> dict[str, list[di
163197

164198

165199
def load_bubbles_for_composer(
166-
global_db: sqlite3.Connection, composer_id: str,
167-
) -> dict[str, dict[str, Any]]:
168-
"""Load ``bubbleId:{composer_id}:*`` KV entries into ``{bubble_id: bubble_dict}``.
200+
global_db: sqlite3.Connection,
201+
composer_id: str,
202+
*,
203+
parse_warnings: ParseWarningCollector | None = None,
204+
) -> dict[str, Bubble]:
205+
"""Load ``bubbleId:{composer_id}:*`` KV entries into ``{bubble_id: Bubble}``.
169206
170207
Scoped alternative to :func:`load_bubble_map` for single-conversation assembly;
171208
avoids a full global ``bubbleId:%`` scan.
172209
"""
173-
bubble_map: dict[str, dict[str, Any]] = {}
210+
bubble_map: dict[str, Bubble] = {}
174211
try:
175212
rows = global_db.execute(
176213
"SELECT key, value FROM cursorDiskKV WHERE key LIKE ?",
@@ -179,16 +216,12 @@ def load_bubbles_for_composer(
179216
except sqlite3.Error:
180217
return bubble_map
181218
for row in rows:
182-
parts = row["key"].split(":")
183-
if len(parts) < 3:
184-
continue
185-
bid = parts[2]
186-
try:
187-
b = json.loads(row["value"])
188-
if isinstance(b, dict):
189-
bubble_map[bid] = b
190-
except (json.JSONDecodeError, ValueError, KeyError, TypeError) as e:
191-
_logger.debug("Skipping malformed bubbleId row %s: %s", row["key"], e)
219+
parsed = _parse_bubble_kv_row(
220+
row["key"], row["value"], parse_warnings=parse_warnings
221+
)
222+
if parsed is not None:
223+
bid, bubble = parsed
224+
bubble_map[bid] = bubble
192225
return bubble_map
193226

194227

services/workspace_listing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
warn_workspace_json_read,
1919
)
2020
from utils.workspace_descriptor import read_json_file
21-
from models import ParseWarningCollector
21+
from models import Bubble, ParseWarningCollector
2222
from services.summary_cache import (
2323
fingerprint_workspace_storage,
2424
get_cached_projects,
@@ -146,7 +146,7 @@ def _build_workspace_projects_uncached(
146146
if invalid_workspace_ids:
147147
project_layouts_map = load_project_layouts_map(global_db)
148148

149-
bubble_map: dict[str, dict[str, Any]] = {}
149+
bubble_map: dict[str, Bubble] = {}
150150
invalid_workspace_aliases: dict[str, str] = {}
151151
if invalid_workspace_ids:
152152
invalid_workspace_aliases = infer_invalid_workspace_aliases(

services/workspace_resolver.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def determine_project_for_conversation(
248248
project_name_to_workspace_id: dict[str, str],
249249
workspace_path_to_id: dict[str, str],
250250
workspace_entries: list[dict[str, Any]],
251-
bubble_map: Mapping[str, Bubble | dict[str, Any]],
251+
bubble_map: Mapping[str, Bubble],
252252
composer_id_to_workspace_id: dict[str, str] | None = None,
253253
invalid_workspace_ids: set[str] | None = None,
254254
) -> str | None:
@@ -261,7 +261,7 @@ def determine_project_for_conversation(
261261
project_name_to_workspace_id: Basename-to-workspace-folder map.
262262
workspace_path_to_id: Normalized root path to workspace folder map.
263263
workspace_entries: Output of :func:`services.workspace_db.collect_workspace_entries`.
264-
bubble_map: ``{bubble_id: Bubble | bubble_dict}`` from global KV.
264+
bubble_map: ``{bubble_id: Bubble}`` from global KV loaders.
265265
composer_id_to_workspace_id: Definitive per-workspace composer map; when
266266
``None``, layout and path heuristics are used without this shortcut.
267267
invalid_workspace_ids: Workspace folders marked invalid; mapped IDs in
@@ -380,7 +380,7 @@ def infer_invalid_workspace_aliases(
380380
project_name_map: dict[str, str],
381381
workspace_path_map: dict[str, str],
382382
workspace_entries: list[dict[str, Any]],
383-
bubble_map: Mapping[str, Bubble | dict[str, Any]],
383+
bubble_map: Mapping[str, Bubble],
384384
composer_id_to_ws: dict[str, str],
385385
invalid_workspace_ids: set[str],
386386
) -> dict[str, str]:
@@ -396,7 +396,7 @@ def infer_invalid_workspace_aliases(
396396
project_name_map: Basename map for path resolution.
397397
workspace_path_map: Normalized path map for path resolution.
398398
workspace_entries: Workspace folder entries from storage scan.
399-
bubble_map: ``{bubble_id: Bubble | bubble_dict}`` for path resolution.
399+
bubble_map: ``{bubble_id: Bubble}`` for path resolution.
400400
composer_id_to_ws: Composer-to-workspace map (may point at invalid IDs).
401401
invalid_workspace_ids: Workspace folder names to reassign.
402402

0 commit comments

Comments
 (0)