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
77 changes: 75 additions & 2 deletions src/watchmen/adapters/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,28 @@
What this adapter can and cannot populate
-----------------------------------------
Cursor's store is conversation text + tool *invocations* + an outcome signal +
per-bubble `createdAt` timestamps. We populate:
per-bubble `createdAt` timestamps + a model *label*. We populate:
- `is_error` from `toolFormerData.status` ("error"/"cancelled"/… vs
"completed"/"success"), `additionalData.status`, or a `userDecision ==
"rejected"` on older builds. A user rejection counts as an error but carries
NO friction signature — it's not the agent's recurring mistake — matching
how the JSONL adapters treat rejections (#110).
- started_at / ended_at / duration from the bubbles' `createdAt` ISO
timestamps.
- `models` / `model_dominant` from the model NAME the store records (#113
follow-up). Verified against a real install: the authoritative source is the
composer's `modelConfig.modelName`; each *user* bubble (type 1) also stamps
the same value at `modelInfo.modelName` (the field community readers like
CodeBurn read), which we use as a fallback when the composer row is gone.
Under Cursor's Auto mode the store records the literal "default" (not blank,
not "auto"); we normalize that to "auto" so the corpus reads cleanly. There
are no per-model token counts, so `model_dominant` is the MOST FREQUENT
model across the conversation, not the token-weighted one pi.py uses.
But NOT:
- per-turn token usage or cost — the store's `tokenCount` is unreliable
(commonly 0), so token columns and `cost_usd` stay 0. We do NOT estimate
from a price table (per-skill cost attribution, #94, is unavailable).
from a price table (per-skill cost attribution, #94, is unavailable). The
model signal above is the NAME only; it adds no token/cost information.

project_dir comes from the conversation's `context.fileSelections[].uri.fsPath`
when present (the common workspace root of the files the chat touched); when the
Expand Down Expand Up @@ -249,6 +259,43 @@ def _message_ts(msg: dict) -> str | None:
return ts if isinstance(ts, str) and ts else None


def _normalize_model(name) -> str | None:
"""Map a raw Cursor model label to the value we store, or None to skip it.

Verified against a real install: under Auto mode the store records the
literal "default" rather than a concrete model or a blank — we surface that
as "auto" so the corpus carries an explicit, readable Auto signal. Any other
non-empty string is a concrete model selection and passes through verbatim.
"""
if not isinstance(name, str):
return None
name = name.strip()
if not name:
return None
return "auto" if name == "default" else name


def _composer_model(convo: dict) -> str | None:
"""The conversation-level model from `composerData.modelConfig.modelName`.
Present on every composer in a real install (it's the picker's selection),
so it's our primary, most reliable model source."""
cfg = convo.get("modelConfig")
if isinstance(cfg, dict):
return _normalize_model(cfg.get("modelName"))
return None


def _bubble_model(msg: dict) -> str | None:
"""The per-bubble model from `modelInfo.modelName` — the field community
readers (CodeBurn) read. On a real install it's stamped on *user* (type 1)
bubbles, carrying the model that turn was sent with. Used as a fallback when
the composer row (and thus `modelConfig`) is missing."""
mi = msg.get("modelInfo")
if isinstance(mi, dict):
return _normalize_model(mi.get("modelName"))
return None


def _tool_name(msg: dict) -> str | None:
"""A tool call is recorded in `toolFormerData`. Prefer the human-readable
`name` (e.g. "run_terminal_command_v2", verified against a real install);
Expand Down Expand Up @@ -454,10 +501,28 @@ def scan(entry: dict):
if project_dir:
session["project_dir"] = project_dir

# Model label (#113 follow-up). No per-model tokens in the store, so we
# rank by frequency: the composer-level pick (modelConfig) counts once,
# and each bubble that stamps modelInfo adds another vote. model_dominant
# is the most frequent; ties break on first-seen order (the composer pick
# leads). Token/cost columns stay 0 regardless — this is the name only.
model_counts: dict[str, int] = {}
model_order: list[str] = []

def _note_model(m):
if not m:
return
if m not in model_counts:
model_order.append(m)
model_counts[m] = model_counts.get(m, 0) + 1

_note_model(_composer_model(convo))

is_first_user = True
for msg in _iter_messages(conn, composer_id, convo):
mtype = msg.get("type")
session["message_count"] += 1
_note_model(_bubble_model(msg))

ts = _message_ts(msg)
if ts:
Expand Down Expand Up @@ -506,6 +571,14 @@ def scan(entry: dict):
except Exception:
pass

if model_counts:
session["models"] = json.dumps(sorted(model_counts))
# Most frequent wins; first-seen order (model_order) breaks ties, so the
# composer-level pick leads a tie with a single-vote bubble model.
session["model_dominant"] = max(
model_order, key=lambda m: (model_counts[m], -model_order.index(m))
)

a, b = _parse_iso(session["started_at"]), _parse_iso(session["ended_at"])
if a and b:
session["duration_seconds"] = (b - a).total_seconds()
Expand Down
4 changes: 2 additions & 2 deletions src/watchmen/curate.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ def _changelog_label(rel_path: str) -> str:
return rel_path


def write_changelog(out_dir: Path, run_kind: str) -> None:
def write_changelog(out_dir: Path, run_kind: str, args) -> None:
"""Manifest-diff approach. Compares CLAUDE.md + each skills/<slug>/SKILL.md mtime
against the prior _manifest.json snapshot and prepends a dated entry to _changelog.md
if anything was added/updated/removed. No-op if nothing changed."""
Expand Down Expand Up @@ -1325,7 +1325,7 @@ def _curate_one(cand: dict, target_subdir: str) -> tuple[str, str, str | None, R

run_kind = "claude-md regen" if (args.skip_finder and args.skip_skills) else "full curator"
try:
write_changelog(out_dir, run_kind)
write_changelog(out_dir, run_kind, args)
except Exception as e:
print(f" changelog write failed: {type(e).__name__}: {e}", flush=True)

Expand Down
105 changes: 103 additions & 2 deletions tests/test_adapter_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

import json
import os
import sqlite3
from pathlib import Path

Expand Down Expand Up @@ -136,8 +137,10 @@ def test_scan_modern_joins_bubbles_with_tool_and_skill(tmp_path):
assert session["assistant_text_count"] == 1 # b2 has text, b3 is empty
assert session["assistant_thinking_count"] == 1 # b2 thinking
assert session["tool_use_count"] == 2
# project_dir is the common root of the two referenced files
assert session["project_dir"] == "/home/u/proj/src"
# project_dir is the common root of the two referenced files. The adapter
# derives it via os.path.commonpath, which uses the host's separator — "\" on
# Windows — so compare against an os.sep-normalized path rather than a literal.
assert session["project_dir"] == os.path.normpath("/home/u/proj/src")
assert prompts[0]["text"] == "run the ble scan skill"

skill_rows = [t for t in tool_calls if t["skill_name"]]
Expand Down Expand Up @@ -316,6 +319,104 @@ def test_numeric_tool_id_falls_back_to_tool_n(tmp_path):
assert tool_calls[0]["tool_name"] == "tool_7"


def test_model_from_composer_modelconfig(tmp_path):
"""The conversation-level model comes from `composerData.modelConfig`, the
authoritative source verified against a real install. A concrete pick passes
through verbatim into models / model_dominant; tokens/cost stay zero (the
model signal is the NAME only, #113 follow-up)."""
db = tmp_path / "state.vscdb"
_make_db(db, {
"composerData:c": {
"composerId": "c",
"modelConfig": {"modelName": "composer-2.5", "maxMode": False,
"selectedModels": [{"modelId": "composer-2.5"}]},
"conversation": [
{"type": 1, "bubbleId": "b1", "text": "hi",
"modelInfo": {"modelName": "composer-2.5"}},
{"type": 2, "bubbleId": "b2", "text": "hello"},
],
},
})
session, _prompts, _tool_calls = _scan(db, "c")
assert session["models"] == json.dumps(["composer-2.5"])
assert session["model_dominant"] == "composer-2.5"
# name only — no tokens/cost leak in.
assert session["input_tokens"] == 0 and session["output_tokens"] == 0
assert session["cost_usd"] == 0.0


def test_auto_mode_default_normalizes_to_auto(tmp_path):
"""Under Cursor's Auto mode the store records the literal "default" (verified
against a real install — not blank, not "auto"). We normalize it to "auto"
so the corpus carries an explicit Auto signal."""
db = tmp_path / "state.vscdb"
_make_db(db, {
"composerData:c": {
"composerId": "c",
"modelConfig": {"modelName": "default", "maxMode": False},
"conversation": [{"type": 1, "bubbleId": "b1", "text": "hi"}],
},
})
session, _prompts, _tool_calls = _scan(db, "c")
assert session["models"] == json.dumps(["auto"])
assert session["model_dominant"] == "auto"


def test_model_falls_back_to_bubble_modelinfo(tmp_path):
"""When the composerData row is gone (recovered from standalone bubbles), the
model is recovered from each user bubble's `modelInfo.modelName` — the field
community readers (CodeBurn) read."""
db = tmp_path / "state.vscdb"
_make_db(db, {
# no composerData row at all — recovered via bubble keys
"bubbleId:orphan:b1": {"type": 1, "text": "first",
"modelInfo": {"modelName": "composer-2.5"}},
"bubbleId:orphan:b2": {"type": 2, "text": "reply"},
"bubbleId:orphan:b3": {"type": 1, "text": "second",
"modelInfo": {"modelName": "composer-2.5"}},
})
session, _prompts, _tool_calls = _scan(db, "orphan")
assert session["models"] == json.dumps(["composer-2.5"])
assert session["model_dominant"] == "composer-2.5"


def test_model_dominant_is_most_frequent(tmp_path):
"""No per-model tokens in the store, so model_dominant is the MOST FREQUENT
model (not token-weighted like pi). Here two bubbles use model-b vs the
composer pick + one bubble on model-a, so model-b wins on count."""
db = tmp_path / "state.vscdb"
_make_db(db, {
"composerData:c": {
"composerId": "c",
"modelConfig": {"modelName": "model-a"},
"conversation": [
{"type": 1, "bubbleId": "b1", "text": "q1",
"modelInfo": {"modelName": "model-b"}},
{"type": 1, "bubbleId": "b2", "text": "q2",
"modelInfo": {"modelName": "model-b"}},
],
},
})
session, _prompts, _tool_calls = _scan(db, "c")
# model-a: 1 (composer), model-b: 2 (bubbles) -> sorted list, b dominant
assert session["models"] == json.dumps(["model-a", "model-b"])
assert session["model_dominant"] == "model-b"


def test_no_model_leaves_fields_empty(tmp_path):
"""A conversation with no modelConfig and no bubble modelInfo keeps the
original empty default (models="[]", model_dominant=None) — we don't invent
a model where the store records none."""
db = tmp_path / "state.vscdb"
_make_db(db, {
"composerData:c": {"composerId": "c", "conversation": [
{"type": 1, "bubbleId": "b1", "text": "hi"}]},
})
session, _prompts, _tool_calls = _scan(db, "c")
assert session["models"] == "[]"
assert session["model_dominant"] is None


def test_scan_unknown_project_when_no_file_context(tmp_path):
db = tmp_path / "state.vscdb"
_make_db(db, {
Expand Down
Loading