# Plan: Cursor cursorDiskKV (vscdb) Transform — P0 + P1

## Context

The Cursor V1 transform reads plain-text `.txt` transcripts → `NormalizedMessage` with fake timestamps (file mtime), zero token counts, no model, and guessed tool result status. The rich data lives in `state.vscdb` → `cursorDiskKV`. This plan adds a **new source type `"cursor_vscdb"`** that runs alongside V1 (`"cursor"`), allowing side-by-side validation before migration.

## Coexistence Strategy

- **V1 (`source="cursor"`)**: Unchanged. Ingests `.txt` transcripts via existing pipeline.
- **V2 (`source="cursor_vscdb"`)**: New. Ingests `state.vscdb` via new pipeline.
- Both run simultaneously. Same composerId appears in both → compare output quality.
- When validated, stop V1 and optionally rename source.

---

## Files Changed

### Modified (existing)

| File | Change |
|------|--------|
| `qc_trace/schemas/unified.py` | Add `"cursor_vscdb"` to `SourceType` literal |
| `qc_trace/daemon/config.py` | Add `cursor_vscdb_glob` field |
| `qc_trace/daemon/watcher.py` | Add `("cursor_vscdb", ...)` to `source_globs` |
| `qc_trace/daemon/collector.py` | Add `"cursor_vscdb": _collect_cursor_vscdb` to collectors dict, new `_collect_cursor_vscdb()` function |
| `qc_trace/schemas/cursor/__init__.py` | Re-export v2 types + `transform_cursor_vscdb` |

### New

| File | Purpose |
|------|---------|
| `qc_trace/utils/vscdb.py` | SQLite reader: open vscdb, decompress, parse, yield sessions |
| `qc_trace/schemas/cursor/v2.py` | Frozen TypedDicts for vscdb data shapes |
| `qc_trace/schemas/cursor/transform_vscdb.py` | Core transform: 3 storage modes → `list[NormalizedMessage]` |
| `tests/test_vscdb.py` | Reader tests (in-memory SQLite) |
| `tests/test_cursor_vscdb_transform.py` | Transform tests per mode |
| `tests/fixtures/cursor_vscdb/` | JSON fixtures for each mode |

### NOT modified

`collector.py` V1 path, `transform.py`, `v1.py`, `cursor_parser.py` — all untouched.

---

## New File Details

### 1. `qc_trace/utils/vscdb.py`

```python
@dataclass
class VscdbSession:
    composer_id: str
    composer_data: dict               # composerData:<id> parsed JSON
    bubble_entries: dict[str, dict]   # bubbleId:<cid>:<bid> → parsed JSON
    agent_kv_entries: dict[str, bytes]  # agentKv:blob:<hash> → raw bytes
    db_path: str

def iter_sessions(db_path: str) -> Iterator[VscdbSession]
def read_item_table(db_path: str, key: str) -> str | None

# Internal:
def _read_kv_rows(db_path: str, prefix: str) -> dict[str, bytes]
def _decompress(raw: bytes) -> bytes       # zlib 0x78 0x9c check
def _parse_json(raw: bytes) -> Any | None  # decompress + json.loads
```

Grouping: read all `composerData:*` rows, for each composerId batch-read `bubbleId:<cid>:*`. Load all `agentKv:blob:*` into shared dict (content-addressed across sessions).

### 2. `qc_trace/schemas/cursor/v2.py`

```python
class CursorTimingInfo(TypedDict, total=False)        # clientStartTime, clientRpcSendTime, clientSettleTime, clientEndTime
class CursorModelConfig(TypedDict, total=False)         # modelName, maxMode
class CursorVscdbBubble(TypedDict, total=False)        # type, bubbleId, text, timingInfo, tokenCountUpUntilHere, isCapabilityIteration, capabilityType, allThinkingBlocks
class CursorBubbleTokenCount(TypedDict, total=False)    # inputTokens, outputTokens
class CursorBubbleIdEntry(TypedDict, total=False)       # _v, type, tokenCount, createdAt
class CursorAgentKvToolResult(TypedDict, total=False)   # role, id, content[]
```

### 3. `qc_trace/schemas/cursor/transform_vscdb.py`

```python
def transform_cursor_vscdb(session: VscdbSession) -> list[NormalizedMessage]
    # Dispatches based on _detect_mode()

def _transform_inline(session) -> list[NormalizedMessage]
    # conversation[] → user/assistant/tool_call msgs
    # Timestamps: timingInfo.clientStartTime (assistant), composerData.createdAt (user/fallback)
    # Tool calls (isCapabilityIteration): inherit parent assistant timestamp
    # Tokens: bubbleId lookup

def _transform_hashchain(session) -> list[NormalizedMessage]
    # conversationState → base64 → protobuf → SHA-256 → agentKv:blob:<hash>
    # role:tool → tool_result with real status
    # Timestamps: composerData.createdAt (session-level only)

def _transform_headers_only(session) -> list[NormalizedMessage]
    # fullConversationHeadersOnly → bubbleId lookups
    # Tokens from bubbleId entries, minimal content
    # Timestamps: bubbleId.createdAt (v3) or composerData.createdAt

# Helpers:
def _detect_mode(cd: dict) -> Literal["inline", "hashchain", "headers_only"]
def _extract_hashes(conversation_state: str) -> list[str]   # base64 → protobuf → hex
def _decode_varint(data: bytes, pos: int) -> tuple[int, int]
def _ms_to_iso(ms: int) -> str
def _extract_model(cd: dict) -> str | None                  # modelConfig.modelName
def _bubble_tokens(session, composer_id, bubble_id) -> TokenUsage
def _parse_agent_kv_json(raw: bytes) -> dict | None
```

---

## Daemon Wiring (Minimal)

### `unified.py` — one-line change
```python
SourceType = Literal["claude_code", "codex_cli", "gemini_cli", "cursor", "cursor_vscdb"]
```

### `config.py` — one field
```python
cursor_vscdb_glob: str = "Library/Application Support/Cursor/User/globalStorage/state.vscdb"
```

### `watcher.py` — one line in source_globs
```python
("cursor_vscdb", self._config.cursor_vscdb_glob),
```

### `collector.py` — new collector function
```python
# In collect_file() collectors dict:
"cursor_vscdb": _collect_cursor_vscdb,

def _collect_cursor_vscdb(changed, existing_state, ...):
    content_hash = _hash_file(changed.path)
    if existing_state and existing_state.content_hash == content_hash:
        return CollectResult(messages=[], new_state=...)

    messages = []
    email = read_item_table(changed.path, "cursorAuth/cachedEmail")
    for session in iter_sessions(changed.path):
        msgs = transform_cursor_vscdb(session)
        cd = session.composer_data
        ctx = _build_session_context(
            cwd=None,  # TODO: resolve from workspace mapping
            global_email=email,
            git_branch=cd.get("createdOnBranch"),
            ...
        )
        _attach_context(msgs, ctx)
        messages.extend(msgs)

    return CollectResult(messages=messages, new_state=...)
```

---

## Field Population by Mode

| Field | Inline | Hashchain | Headers-only |
|-------|--------|-----------|--------------|
| timestamp | `timingInfo.clientStartTime` (assistant), `composerData.createdAt` (user) | `composerData.createdAt` | `bubbleId.createdAt` (v3) or `composerData.createdAt` |
| tokens | `bubbleId.tokenCount` | N/A | `bubbleId.tokenCount` |
| model | `modelConfig.modelName` | `modelConfig.modelName` | `modelConfig.modelName` |
| content | `conversation[].text` | `agentKv` content | None |
| tool_result | N/A (stripped in inline) | `agentKv role:tool` ✅ | N/A |
| tool_call | `isCapabilityIteration` bubbles | `agentKv role:assistant` blocks | N/A |

---

## Protobuf Hash Extraction (~30 lines, stdlib only)

1. Strip leading `~`, base64-decode (add `=` padding)
2. Walk wire format: read tag varint → field_number + wire_type
3. Wire type 2 + length 32 → SHA-256 hash
4. Convert to hex → `agentKv:blob:<hex>`

---

## Task Breakdown (6 sub-agent tasks)

### Task 1: `qc_trace/utils/vscdb.py` + `tests/test_vscdb.py` (independent)
- SQLite reader, zlib decompression, JSON parsing, `iter_sessions()`, `read_item_table()`
- Tests use in-memory SQLite fixture

### Task 2: `qc_trace/schemas/cursor/v2.py` (independent)
- Frozen TypedDicts only

### Task 3: `transform_vscdb.py` — inline mode + shared helpers (depends on 1, 2)
- `transform_cursor_vscdb()`, `_detect_mode()`, `_transform_inline()`, `_ms_to_iso()`, `_extract_model()`, `_bubble_tokens()`
- Tests in `tests/test_cursor_vscdb_transform.py`

### Task 4: `transform_vscdb.py` — hashchain mode (depends on 1, 2)
- `_extract_hashes()`, `_decode_varint()`, `_transform_hashchain()`, `_parse_agent_kv_json()`
- Protobuf unit tests

### Task 5: `transform_vscdb.py` — headers-only mode (depends on 1, 2)
- `_transform_headers_only()`
- Tests with headers-only fixture

### Task 6: Daemon wiring + integration test (depends on 3, 4, 5)
- Modify `unified.py`, `config.py`, `watcher.py`, `collector.py`, `__init__.py`
- Integration test: in-memory vscdb → full pipeline → verify NormalizedMessage fields
- Run full test suite to confirm V1 is untouched

**Parallelism:** Tasks 1+2 parallel → Tasks 3+4+5 parallel → Task 6 last.

---

## Verification

```bash
# V1 tests still pass (untouched)
pytest tests/test_cursor_transform.py tests/test_cursor_parser.py -v

# New V2 tests
pytest tests/test_vscdb.py tests/test_cursor_vscdb_transform.py -v

# Full suite
pytest tests/ -v
```

Manual validation:
```python
from qc_trace.utils.vscdb import iter_sessions
from qc_trace.schemas.cursor.transform_vscdb import transform_cursor_vscdb

db = "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
for session in iter_sessions(db):
    msgs = transform_cursor_vscdb(session)
    for m in msgs[:3]:
        print(m.source, m.msg_type, m.timestamp, m.tokens, m.model)
    # source will be "cursor_vscdb"
```
