Skip to content

feat(cursor): read Cursor's dual-store layout and merge per session - #22

Merged
Quantumlyy merged 4 commits into
mainfrom
refactor/cursor
Jun 19, 2026
Merged

Quantumlyy merged 4 commits into
mainfrom
refactor/cursor

Conversation

@Quantumlyy

Copy link
Copy Markdown
Member

Rewrites the cursor source against Cursor's real on-disk storage. The old reader pulled only composerData/bubbleId from state.vscdb, collapsed every session into a single double-prefixed cursor:cursor:unknown grouping, capped workspace resolution at the first 5 bubbles, and surfaced almost no content (54 of 165 real sessions, all tokens 0). It now reads Cursor's full multi-store layout and merges per session.

The two stores

Cursor keeps the same conversation in two places, and they are not interchangeable:

  • Global state (state.vscdb, cursorDiskKV: composerData:<sid> + bubbleId:<sid>:<bid>) — the richer store. Carries assistant prose, thinking reasoning, toolFormerData tool calls with results and status, tokenCount, model (modelConfig.modelName), title (name), and timestamps.
  • agent-transcripts JSONL (~/.cursor/projects/<project-dir>/agent-transcripts/..., flat and nested) — clean prose + tool-call inputs only, but the <project-dir> name decodes to the authoritative workspace path.

For sessions present in both (the common case) the records are merged into one: bubbles are the content/metrics backbone, the transcript supplies the workspace. Sessions in only one store are read from that store. A session is never emitted twice.

What's in the source

  • Bubbles assemble into turns in header order: text, thinking→Reasoning, toolFormerData→ToolCall (input/output/status). Tokens, model, title, and createdAt come from composerData.
  • Transcript reading models the claudecode source (flat + nested layouts; on-disk greedy decode of dash-encoded project dirs so hyphenated repo names like vibe-replay resolve correctly).
  • Sub-agents (Cursor spawns them via task_v2; each gets its own composerData + bubbles + a subagents/ transcript) are emitted as separate records tagged Provenance.SourceOrigin = "cursor-subagent" with a parent_session_id link. Discriminated authoritatively by composerData.subagentInfo (which also carries parentComposerId).
  • A composer whose bubble rows were GC'd but whose header survives falls back to its transcript, keeping the composer-derived title/model/start time.
  • LookupSession resolves both composerData and transcript ids (the orchestrator keys cursor lookups on a composerData id). The session index is memoized for the extraction pass.
  • Removed: the double-prefix bug and the 5-bubble workspace cap. Unresolvable window sentinels (empty-window, numeric ids) bucket as cursor:unknown instead of fabricated paths.

Deviations from the original spec (each empirically justified)

  • Bubbles, not transcripts, are primary for overlapping sessions. The spec assumed bubbles were lossy; measurement disproved it — prose is ~identical (1.656M vs 1.655M chars) but only bubbles carry reasoning, tool results, tokens, model, and timestamps.
  • The agentKv-by-requestId join is skipped. It is dead on current builds: 0 composerData reference agentKv:blob: and 0 store.db files exist; the content it was meant to recover is already in the bubbles. Gated on a post-build assertion that every real composer emits a non-empty record.
  • The storage.json workspace map is dropped. Project-dir + workspaceIdentifier + bubble workspaceUris attribute 100% of sessions, and there is no reliable composer→workspace-hash join.

Verification

Run against a live store: 227 records (95 top-level + 132 sub-agents, all parent-linked) across 30 workspaces, 0 unknown / duplicate / empty records, 165/165 with model and title — up from 54 records in one junk grouping.

Tests

go test ./... is green. The cursor suite grew from 5 to 20 tests covering the dual-store merge and bubble preference, sub-agent tagging + parent links + the subagentInfo classification override, the GC'd-bubble transcript fallback (including composer-metadata preservation), workspace/sentinel decoding, double-prefix and dedup invariants, and LookupSession for both id types.

Rewrite the Cursor source against Cursor's real on-disk storage. The old
reader pulled only composerData/bubbleId from state.vscdb, dumped every
session into a single double-prefixed `cursor:cursor:unknown` grouping,
capped workspace resolution at the first 5 bubbles, and surfaced almost no
content.

It now reads both stores and merges by session id into one record each:

- Global-state (composerData + bubbleId) is the content/metrics backbone:
  turns from bubbles in header order (text, thinking->reasoning,
  toolFormerData->tool calls with results+status), plus model, title,
  tokens, and timestamps.
- agent-transcripts JSONL (flat + nested) supply the authoritative workspace
  (decoded project-dir name) and cover transcript-only sessions.
- Sub-agent conversations are emitted as separate records tagged
  Provenance.SourceOrigin="cursor-subagent" with a parent_session_id link
  (discriminated by composerData.subagentInfo / the subagents/ transcript
  path). Sub-agents are never emitted twice or as plain top-level peers.
- Composer sessions whose bubble rows were GC'd fall back to their transcript
  while keeping composer-derived title/model/start time.
- buildIndex is memoized for the extraction pass; LookupSession resolves both
  composerData and transcript ids for the orchestrator. Double-prefix and the
  5-bubble cap are gone; unresolvable window sentinels bucket as cursor:unknown.

Verified against a live store: 227 records (95 top-level + 132 sub-agents)
across 30 workspaces, 0 unknown/duplicate/empty records.

Deviations from the original spec, each empirically justified:
- Bubbles, not transcripts, are PRIMARY for overlapping sessions: prose is
  ~identical (1.656M vs 1.655M chars) but only bubbles carry reasoning, tool
  results, tokens, model, and timestamps.
- The agentKv-by-requestId join is skipped: dead on current builds (0
  composerData reference agentKv:blob, 0 store.db files); bubbles already
  carry the content. Gated on a completeness assertion.
- The storage.json workspace map is dropped: project-dir + workspaceIdentifier
  + bubble workspaceUris attribute 100% of sessions with no reliable
  composer->workspace-hash join available.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 84cf1a1a-4b71-4bc3-8f04-05b7a9370533

📥 Commits

Reviewing files that changed from the base of the PR and between 7495e64 and 0c09b39.

📒 Files selected for processing (2)
  • internal/source/cursor/source.go
  • internal/source/cursor/source_test.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added session lookup by ID with merged results.
    • Improved Cursor session discovery/extraction by scanning and merging both global state and on-disk transcript files.
  • Refactor
    • Unified, memoized session indexing and record-building across Cursor storage layouts (including sub-agent/nested layouts).
    • Improved bubble/tool-call and transcript role normalization, plus reasoning suppression options.
  • Bug Fixes
    • Fixed workspace labels to avoid duplicated prefixes; improved workspace inference and missing-data fallback behavior.
  • Tests
    • Expanded CursorDB/JSONL fixtures and scenario tests for merging, sub-agents, reasoning handling, and workspace edge cases.

Walkthrough

The Cursor source extractor is refactored to build a memoized, unified session index from both global-state state.vscdb composerData rows and per-project agent-transcripts JSONL files. Discover, Extract, and a new LookupSession method all derive their data from this shared index, with record construction preferring composer bubble rows and falling back to transcript turns when bubbles are GC'd. Tests are significantly expanded to cover all new paths.

Changes

Cursor Unified Session Index Extraction

Layer / File(s) Summary
Source struct and Discover/Extract/LookupSession entry points
internal/source/cursor/source.go
Source gains projectsRoot, mu, and cache fields for memoized session indexing. Discover derives workspace groupings from the unified index. Extract iterates the index, builds merged records, suppresses reasoning on request, and warns on empty sessions. LookupSession is added as a new public method. defaultProjectsRoot and defaultDBPath helpers are introduced.
Unified session index construction and centralized record building
internal/source/cursor/source.go
index()/buildIndex() scan transcript JSONL files and composerData KV rows into a single sessionEntry map, decode subagentInfo, and attribute workspaces. buildRecord() uses composer bubble rows as primary content, falls back to transcript turns when bubbles are GC'd, merges timestamps, sets provenance/extension fields, and marks subagent sessions with cursor-subagent origin. recordFromComposer, recordFromTranscript, and bubbleToTurn perform role/content normalization and turn assembly.
Workspace resolution, transcript scanning, project-dir decoding, and helper transforms
internal/source/cursor/source.go
composerWorkspace() prefers workspaceIdentifier.uri then scans header bubbles. scanTranscripts/collectTranscripts walk projectsRoot for JSONL files; classifySubagentTranscript detects subagent nesting. decodeProjectDir decodes dash-encoded directory names with greedy filesystem resolution and sentinel handling. Workspace-to-path conversion, role normalization, text concatenation, and usage finalization helpers are added.
Parsing and helper utilities
internal/source/cursor/source.go
selectedModelID, suppressReasoning, finalizeUsage, and displayLabel helpers support record building. Inline bubble-id extraction adjustment. Transcript/bubble role normalization and reasoning-suppression logic.
Test fixture structs, DB builder, and transcript helpers
internal/source/cursor/source_test.go
composerSpec is expanded with name/model/createdAt/workspaceURI/subagent/parent fields; buildCursorDB generates conditional composerData rows. New helpers tLine, tText, tToolUse, writeTranscript, writeSubagentTranscript, newTestSource, extractAll, and recordByID support all new test scenarios.
Extraction, discovery, and merge test scenarios
internal/source/cursor/source_test.go
Tests cover transcript-only extraction, nested transcript layout, global-state composerData mapping, skipping empty composers, merged-session deduplication preferring bubble-derived content, workspace grouping label formatting, unknown-workspace bucketing, and nil return when stores are missing.
Subagent provenance, GC fallback, reasoning suppression, and edge case regressions
internal/source/cursor/source_test.go
Tests cover LookupSession by composer and transcript ID; cursor-subagent tagging and parent_session_id propagation; subagentInfo override on ID collision; GC-fallback staying tagged; transcript-only provenance when DB is unavailable; reasoning suppression and usage recomputation; default root for New(""); selected-model payload decoding; thinking-block-to-reasoning conversion; and decodeProjectDir regression for hyphenated paths.

Poem

A rabbit hops through project dirs,
decoding dashes, reading files in tiers.
Two stores now merge to one index true,
sub-agents tagged and bubbles GC'd too.
No double prefix on the label's face —
🐇 the session cache is now in place!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(cursor): read Cursor's dual-store layout and merge per session' directly and clearly summarizes the main change: implementing dual-store reading and per-session merging for the Cursor source.
Description check ✅ Passed The description comprehensively explains the dual-store merge strategy, changes from the previous implementation, verification results, and deviations from the original specification, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/cursor

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.25962% with 53 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/source/cursor/source.go 87.25% 25 Missing and 28 partials ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR rewrites the cursor source to correctly model Cursor's real dual-store layout: a richer global-state SQLite DB (state.vscdb, via composerData + bubbleId rows) and the agent-transcripts JSONL tree. Sessions present in both stores are merged into one record — bubbles provide the content/metrics backbone (reasoning, tool results, tokens, model, timestamps) while the transcript supplies the authoritative workspace path via project-dir decoding.

  • Dual-store index: buildIndex unifies sessions from both stores in a single pass, applying subagentInfo as the authoritative sub-agent discriminator and memoizing the result across Discover/Extract/LookupSession calls.
  • GC'd-bubble fallback: when a composer's bubble rows are gone but the header survives, buildRecord recovers turns from the transcript while preserving composer-derived title, model, and createdAt metadata; provenance correctly reflects the transcript as the turn source.
  • Sub-agents: sessions with composerData.subagentInfo are emitted as separate records tagged "cursor-subagent" with parent_session_id links; the sentinel/numeric project-dir filter prevents fabricated workspace paths.

Confidence Score: 5/5

Safe to merge — the rewrite is well-contained to the cursor source, all logic paths are covered by tests, and the two previously flagged issues (provenance SourcePath on DB-unavailable path; reasoning-only turn shells) are both resolved and regression-tested.

The dual-store index, per-session merge, GC'd-bubble fallback, sub-agent classification, and LookupSession are all exercised by the expanded 20-test suite. No new correctness issues were found in the merge logic, provenance assembly, workspace decoding, or sentinel filtering.

No files require special attention.

Important Files Changed

Filename Overview
internal/source/cursor/source.go Complete rewrite: introduces dual-store indexing, per-session merge with GC'd-bubble fallback, sub-agent classification via subagentInfo, corrected provenance/SourcePath logic, and workspace sentinel filtering. Previous review items resolved.
internal/source/cursor/source_test.go Test suite grows from 5 to 20 tests covering all major paths: transcript-only, global-state-only, merged sessions, GC'd-bubble fallback, sub-agent tagging, workspace sentinel decoding, double-prefix/dedup invariants, and provenance regression.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[buildIndex] --> B[scanTranscripts]
    A --> C[Query composerData rows]
    B --> D{For each project-dir}
    D --> E[decodeProjectDir + resolveEncodedParts]
    E --> F[collectTranscripts flat+nested+subagents]
    F --> H[sessionEntry workspace/transcriptPath/isSubagent]
    C --> I{For each composerData}
    I --> J{subagentInfo?}
    J -- yes --> K[isSubagent=true parentID from parentComposerId]
    J -- no --> L[top-level]
    K --> M{workspace empty?}
    L --> M
    M -- yes --> N[composerWorkspace URI then bubble workspaceUris]
    N --> O[hasComposer=true]
    M -- no --> O
    H --> P[Unified index]
    O --> P
    P --> Q[buildRecord]
    Q --> R{hasComposer AND db != nil?}
    R -- yes --> S[recordFromComposer metadata+turns+tokens]
    S --> T{Turns empty? GC bubbles}
    T -- yes+transcript --> U[recordFromTranscript turns, keep composer metadata]
    T -- no --> V[Merged record]
    R -- no --> W[recordFromTranscript]
    U --> V
    W --> V
    V --> X{isSubagent?}
    X -- yes --> Y[SourceOrigin=cursor-subagent + parent_session_id]
    X -- no --> Z[SourceOrigin=cursor]
    Y --> AA[Emit]
    Z --> AA
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[buildIndex] --> B[scanTranscripts]
    A --> C[Query composerData rows]
    B --> D{For each project-dir}
    D --> E[decodeProjectDir + resolveEncodedParts]
    E --> F[collectTranscripts flat+nested+subagents]
    F --> H[sessionEntry workspace/transcriptPath/isSubagent]
    C --> I{For each composerData}
    I --> J{subagentInfo?}
    J -- yes --> K[isSubagent=true parentID from parentComposerId]
    J -- no --> L[top-level]
    K --> M{workspace empty?}
    L --> M
    M -- yes --> N[composerWorkspace URI then bubble workspaceUris]
    N --> O[hasComposer=true]
    M -- no --> O
    H --> P[Unified index]
    O --> P
    P --> Q[buildRecord]
    Q --> R{hasComposer AND db != nil?}
    R -- yes --> S[recordFromComposer metadata+turns+tokens]
    S --> T{Turns empty? GC bubbles}
    T -- yes+transcript --> U[recordFromTranscript turns, keep composer metadata]
    T -- no --> V[Merged record]
    R -- no --> W[recordFromTranscript]
    U --> V
    W --> V
    V --> X{isSubagent?}
    X -- yes --> Y[SourceOrigin=cursor-subagent + parent_session_id]
    X -- no --> Z[SourceOrigin=cursor]
    Y --> AA[Emit]
    Z --> AA
Loading

Reviews (3): Last reviewed commit: "fix(cursor): resolve workspace from VS C..." | Re-trigger Greptile

Comment thread internal/source/cursor/source.go
Comment thread internal/source/cursor/source.go
Comment thread internal/source/cursor/source.go
…g shells

Addresses Greptile review on #22:

- buildRecord only labels a record global-state sourced when the DB was
  actually read (hasComposer AND db != nil). A composer whose DB is
  unavailable at extract time falls back to its transcript, so SourcePath
  now points at the transcript and the "stores" extension no longer claims
  global-state was accessed.
- suppressReasoning drops turns left content-less after clearing reasoning
  (a standalone thinking bubble would otherwise become an empty shell) and
  recomputes the usage counts.
- Document the inherent ambiguity of Cursor's lossy dash-encoding in
  resolveEncodedParts (matches the vibe-replay reference; best-effort).
… edges

Raises cursor source coverage from 82.7% to 90.9% (above the pre-rewrite
baseline). New tests exercise SuppressReasoning including reasoning-only
turn removal, the DB-unavailable fallback provenance, New() defaults,
selectedModels model resolution, the bubble-workspaceUris fallback,
tool-param unwrapping, transcript thinking blocks, an unknown bubble type,
and the small decode/normalization edge branches.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/source/cursor/source_test.go`:
- Line 47: Replace the db.Exec() calls with db.ExecContext() to comply with the
noctx linting rule. In the buildCursorDB function, update both occurrences (the
create table cursorDiskKV statement and the other db.Exec call at line 87) to
use ExecContext instead, passing context.Background() as the first argument
since this is a test helper function that does not have a context parameter
available to plumb through.

In `@internal/source/cursor/source.go`:
- Around line 201-211: The db.Query call in the code lacks context propagation
for cancellation support. Replace db.Query with QueryContext in the query
execution, and thread a context.Context parameter through both the buildIndex()
and index() functions so that the context can be passed to QueryContext. Update
the query call from db.Query to db.QueryContext, passing the context as the
first argument before the SQL query string.
- Around line 785-787: The uriToPath function currently uses only
strings.TrimPrefix to remove the "file://" prefix, which does not handle
URL-encoded characters as specified in RFC 8089. Modify the uriToPath function
to decode the URI path after removing the "file://" prefix by using
url.PathUnescape on the result of TrimPrefix to ensure proper handling of
percent-encoded characters like spaces and special characters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 121f07cc-96d5-43bc-8f74-0176b4d50041

📥 Commits

Reviewing files that changed from the base of the PR and between 442d5b9 and 7495e64.

📒 Files selected for processing (2)
  • internal/source/cursor/source.go
  • internal/source/cursor/source_test.go

Comment thread internal/source/cursor/source_test.go
Comment thread internal/source/cursor/source.go
Comment thread internal/source/cursor/source.go
…ings

Investigating CodeRabbit's URI-handling note on #22 showed that on real
Cursor builds composerData.workspaceIdentifier.uri (and bubble workspaceUris)
are serialized VS Code URI objects ({scheme, path, fsPath, ...}), not
"file://" strings — so the composer-only workspace fallback never matched on
real data. Read the path from the object (fsPath/path) as well as the string
form. Cursor does not percent-encode these, so no URL unescaping is needed.
@Quantumlyy
Quantumlyy merged commit f8cf128 into main Jun 19, 2026
7 checks passed
@Quantumlyy
Quantumlyy deleted the refactor/cursor branch June 19, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant