From c9465db54ca12bfa2ff38bbfec619418a786823d Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sat, 30 May 2026 17:40:56 -0700 Subject: [PATCH 1/6] Add cursor platform constants module Foundation module for the Cursor integration: platform identifiers, hooks.json location, registered hook events, dedicated tool-name set used to dedupe postToolUse events, and the strip-keys set for routing and PII fields removed before payloads land in the event store. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/thirdeye/platforms/cursor/__init__.py | 0 src/thirdeye/platforms/cursor/constants.py | 65 +++++++++++++++++++++ tests/test_cursor_constants.py | 66 ++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/thirdeye/platforms/cursor/__init__.py create mode 100644 src/thirdeye/platforms/cursor/constants.py create mode 100644 tests/test_cursor_constants.py diff --git a/src/thirdeye/platforms/cursor/__init__.py b/src/thirdeye/platforms/cursor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/thirdeye/platforms/cursor/constants.py b/src/thirdeye/platforms/cursor/constants.py new file mode 100644 index 0000000..aeb3e5b --- /dev/null +++ b/src/thirdeye/platforms/cursor/constants.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +PLATFORM_NAME = "cursor" +DISPLAY_NAME = "Cursor" + +HOOKS_FILE = Path.home() / ".cursor" / "hooks.json" + +HOOK_BIN_NAME = "thirdeye-cursor-hook" + +HOOK_TIMEOUT_S = 30 + +TRACED_EVENTS: tuple[str, ...] = ( + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "afterAgentResponse", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "beforeTabFileRead", + "afterTabFileEdit", + "postToolUse", + "stop", +) + +DEDICATED_TOOL_NAMES: frozenset[str] = frozenset( + { + "shell", + "terminal", + "bash", + "run_command", + "run_shell", + "read_file", + "read", + "view_file", + "view", + "edit_file", + "edit", + "write_file", + "write", + "create_file", + "delete_file", + "tab_file_read", + "tab_file_edit", + "mcp", + "mcp_execution", + } +) + +STRIP_KEYS: frozenset[str] = frozenset( + { + "conversation_id", + "conversationId", + "workspace_roots", + "transcript_path", + "user_email", + "hook_event_name", + "hookEventName", + } +) diff --git a/tests/test_cursor_constants.py b/tests/test_cursor_constants.py new file mode 100644 index 0000000..8e09d3d --- /dev/null +++ b/tests/test_cursor_constants.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from thirdeye.platforms.cursor import constants + + +class TestNames: + def test_platform_name(self): + assert constants.PLATFORM_NAME == "cursor" + + def test_display_name(self): + assert constants.DISPLAY_NAME == "Cursor" + + def test_hook_bin_name(self): + assert constants.HOOK_BIN_NAME == "thirdeye-cursor-hook" + + +class TestHooksFile: + def test_under_cursor_dir(self): + assert constants.HOOKS_FILE.name == "hooks.json" + assert constants.HOOKS_FILE.parent.name == ".cursor" + + +class TestTracedEvents: + def test_contains_all_documented_events(self): + expected = { + "sessionStart", "sessionEnd", + "beforeSubmitPrompt", "afterAgentResponse", + "beforeShellExecution", "afterShellExecution", + "beforeMCPExecution", "afterMCPExecution", + "beforeReadFile", "afterFileEdit", + "beforeTabFileRead", "afterTabFileEdit", + "postToolUse", "stop", + } + assert set(constants.TRACED_EVENTS) == expected + + def test_is_tuple_not_list(self): + assert isinstance(constants.TRACED_EVENTS, tuple) + + def test_no_duplicates(self): + assert len(constants.TRACED_EVENTS) == len(set(constants.TRACED_EVENTS)) + + +class TestDedicatedToolNames: + def test_includes_shell_variants(self): + for name in ("shell", "bash", "terminal", "run_command"): + assert name in constants.DEDICATED_TOOL_NAMES + + def test_includes_file_variants(self): + for name in ("read_file", "edit_file", "write_file"): + assert name in constants.DEDICATED_TOOL_NAMES + + def test_includes_mcp(self): + assert "mcp" in constants.DEDICATED_TOOL_NAMES + + +class TestStripKeys: + def test_strips_routing_keys(self): + for key in ("conversation_id", "conversationId", "hook_event_name", "hookEventName"): + assert key in constants.STRIP_KEYS + + def test_strips_pii(self): + assert "user_email" in constants.STRIP_KEYS + + def test_strips_noisy_paths(self): + assert "transcript_path" in constants.STRIP_KEYS + assert "workspace_roots" in constants.STRIP_KEYS From 8e607ba484f75edcd2c50fde207be0eb7e7f20a4 Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sat, 30 May 2026 17:41:05 -0700 Subject: [PATCH 2/6] docs: add Cursor to supported platform mentions Update README tagline, browser UI doc filter list, and the use-thirdeye setup reference so Cursor is documented alongside Claude, Codex, and Gemini. Notes Cursor's hook config lives at ~/.cursor/hooks.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/ui.md | 5 +++-- .../skills/use-thirdeye/references/setup-and-tracing.md | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3eb4173..0b9adfc 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Python](https://img.shields.io/pypi/pyversions/thrdi.svg)](https://pypi.org/project/thrdi/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Trace every agent session on your machine — Claude Code, Codex, Gemini — into one history you and your agents can manage, search, and evaluate. +Trace every agent session on your machine — Claude Code, Codex, Gemini, Cursor — into one history you and your agents can manage, search, and evaluate. ## Install diff --git a/docs/ui.md b/docs/ui.md index 43becb5..4d0e7cc 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -41,8 +41,9 @@ The 'ui' extra is required. Install it with: pip install 'thrdi[ui]' ## What you can do - **Browse sessions.** The list at `/` shows every recorded session - across every platform, with filters for platform, cwd, status (open / - closed / stale), date range, and a **tag multi-select** dropdown + across every platform (claude, codex, gemini, cursor), with filters + for platform, cwd, status (open / closed / stale), date range, and a + **tag multi-select** dropdown populated from every tag in your history. Defaults to the last 7 days, newest first. The active filter persists in `localStorage` so navigating away and back keeps your view. diff --git a/src/thirdeye/skills/use-thirdeye/references/setup-and-tracing.md b/src/thirdeye/skills/use-thirdeye/references/setup-and-tracing.md index 8ea00b2..d0b0e94 100644 --- a/src/thirdeye/skills/use-thirdeye/references/setup-and-tracing.md +++ b/src/thirdeye/skills/use-thirdeye/references/setup-and-tracing.md @@ -26,6 +26,11 @@ thirdeye add --copilot # GitHub Copilot CLI `thirdeye add` is idempotent — running it twice for the same platform leaves the existing hook entries in place rather than duplicating them. +Hook entries are written into each platform's own config file: Claude Code +uses `~/.claude/settings.json`, Codex uses `~/.codex/config.toml`, Gemini +uses `~/.gemini/settings.json`, and Cursor uses `~/.cursor/hooks.json` +(covering both the IDE chat and the `cursor-agent` CLI). + ## Detach ```bash From b2f5051bf4127d33c632dc4c71a799ceca287d35 Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sat, 30 May 2026 17:45:26 -0700 Subject: [PATCH 3/6] Add cursor usage capture from stop/sessionEnd payload Implements capture_usage_cursor which extracts model and token counts directly from the Cursor hook payload (no transcript parsing). Handles both snake_case (IDE) and camelCase (CLI) field variants, skips writes when required fields are missing, and dedups between stop/sessionEnd fallback calls via UsageStore state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/thirdeye/platforms/cursor/usage.py | 81 +++++++++ tests/test_cursor_usage.py | 241 +++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/thirdeye/platforms/cursor/usage.py create mode 100644 tests/test_cursor_usage.py diff --git a/src/thirdeye/platforms/cursor/usage.py b/src/thirdeye/platforms/cursor/usage.py new file mode 100644 index 0000000..ab12727 --- /dev/null +++ b/src/thirdeye/platforms/cursor/usage.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from thirdeye.paths import session_dir +from thirdeye.usage.errlog import safe_capture +from thirdeye.usage.store import UsageStore +from thirdeye.usage.types import UsageRow + + +def _get_str(payload: dict, *keys: str, default: str = "") -> str: + """Return the first non-empty string-coerced value among the keys.""" + for k in keys: + v = payload.get(k) + if v is not None and v != "": + return str(v) + return default + + +def _get_int(payload: dict, *keys: str) -> int | None: + """Return the first parseable int among the keys; None if none parse. + + Accepts numeric strings (including "0") but rejects "" and "--". + """ + for k in keys: + v = payload.get(k) + if v is None or v == "" or v == "--": + continue + try: + return int(v) + except (TypeError, ValueError): + continue + return None + + +@safe_capture(phase="extract_usage", platform="cursor") +def capture_usage_cursor( + *, + thirdeye_home: Path, + session_id: str, + payload: dict[str, Any], + triggering_seq: int, +) -> int: + """Append a UsageRow extracted from a Cursor stop/sessionEnd payload. + + Returns the number of rows appended (0 or 1). Wrapped in @safe_capture so + any exception is logged to usage-errors.jsonl and the function returns None + instead of raising. + """ + model = _get_str(payload, "model", "model_name") + input_tokens = _get_int(payload, "input_tokens", "inputTokens") + output_tokens = _get_int(payload, "output_tokens", "outputTokens") + + if not model or input_tokens is None or output_tokens is None: + return 0 + + sd = session_dir(thirdeye_home, "cursor", session_id) + store = UsageStore(sd) + state = store.read_state() + if state.get("last_seq", -1) >= 0: + return 0 + + row = UsageRow( + session_id=session_id, + seq=triggering_seq, + ts=_now_iso(), + platform="cursor", + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=input_tokens + output_tokens, + ) + store.append([row]) + store.write_state(last_seq=triggering_seq) + return 1 + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) diff --git a/tests/test_cursor_usage.py b/tests/test_cursor_usage.py new file mode 100644 index 0000000..da7e54a --- /dev/null +++ b/tests/test_cursor_usage.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from thirdeye.paths import session_dir, usage_log_path +from thirdeye.platforms.cursor.usage import ( + _get_int, + _get_str, + capture_usage_cursor, +) +from thirdeye.usage.store import UsageStore + + +SESSION_ID = "test-conv-123" + + +def _store_for(tmp_path: Path) -> UsageStore: + return UsageStore(session_dir(tmp_path, "cursor", SESSION_ID)) + + +def _rows(tmp_path: Path) -> list: + return list(_store_for(tmp_path).iter_rows()) + + +def _complete_snake_payload(**overrides: Any) -> dict[str, Any]: + payload = { + "model": "composer-1", + "input_tokens": 100, + "output_tokens": 50, + } + payload.update(overrides) + return payload + + +def _complete_camel_payload(**overrides: Any) -> dict[str, Any]: + payload = { + "model": "composer-1", + "inputTokens": 100, + "outputTokens": 50, + } + payload.update(overrides) + return payload + + +class TestGetStr: + def test_first_key_present_wins(self) -> None: + assert _get_str({"model": "a", "model_name": "b"}, "model", "model_name") == "a" + + def test_falls_back_to_second_key(self) -> None: + assert _get_str({"model_name": "b"}, "model", "model_name") == "b" + + def test_empty_string_falls_through(self) -> None: + assert _get_str({"model": "", "model_name": "b"}, "model", "model_name") == "b" + + def test_returns_default_when_all_missing(self) -> None: + assert _get_str({}, "model", "model_name", default="x") == "x" + assert _get_str({}, "model", "model_name") == "" + + +class TestGetInt: + def test_int_value(self) -> None: + assert _get_int({"input_tokens": 5}, "input_tokens") == 5 + + def test_string_int_value(self) -> None: + assert _get_int({"input_tokens": "42"}, "input_tokens") == 42 + + def test_zero_is_valid(self) -> None: + assert _get_int({"input_tokens": 0}, "input_tokens") == 0 + assert _get_int({"input_tokens": "0"}, "input_tokens") == 0 + + def test_empty_string_falls_through(self) -> None: + assert _get_int({"input_tokens": "", "inputTokens": 7}, "input_tokens", "inputTokens") == 7 + + def test_dash_dash_falls_through(self) -> None: + assert _get_int({"input_tokens": "--", "inputTokens": 9}, "input_tokens", "inputTokens") == 9 + + def test_returns_none_when_all_missing(self) -> None: + assert _get_int({}, "input_tokens", "inputTokens") is None + + def test_unparseable_falls_through_then_returns_none(self) -> None: + assert _get_int({"input_tokens": "abc"}, "input_tokens") is None + assert _get_int({"input_tokens": "abc", "inputTokens": "xyz"}, "input_tokens", "inputTokens") is None + + +class TestCaptureFromCompletePayload: + def test_writes_one_row_with_correct_fields(self, tmp_path: Path) -> None: + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=7, + ) + assert result == 1 + rows = _rows(tmp_path) + assert len(rows) == 1 + row = rows[0] + assert row.session_id == SESSION_ID + assert row.model == "composer-1" + assert row.input_tokens == 100 + assert row.output_tokens == 50 + assert row.total_tokens == 150 + + def test_camelcase_payload_works(self, tmp_path: Path) -> None: + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_camel_payload(), + triggering_seq=3, + ) + assert result == 1 + rows = _rows(tmp_path) + assert len(rows) == 1 + assert rows[0].input_tokens == 100 + assert rows[0].output_tokens == 50 + assert rows[0].total_tokens == 150 + + def test_zero_tokens_writes_row(self, tmp_path: Path) -> None: + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(input_tokens=0, output_tokens=0), + triggering_seq=1, + ) + assert result == 1 + rows = _rows(tmp_path) + assert len(rows) == 1 + assert rows[0].input_tokens == 0 + assert rows[0].output_tokens == 0 + assert rows[0].total_tokens == 0 + + def test_uses_triggering_seq(self, tmp_path: Path) -> None: + capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=42, + ) + rows = _rows(tmp_path) + assert rows[0].seq == 42 + + def test_platform_is_cursor(self, tmp_path: Path) -> None: + capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=1, + ) + rows = _rows(tmp_path) + assert rows[0].platform == "cursor" + + +class TestCaptureSkipsIncomplete: + def test_missing_model_returns_zero(self, tmp_path: Path) -> None: + payload = _complete_snake_payload() + payload.pop("model") + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=payload, + triggering_seq=1, + ) + assert result == 0 + + def test_missing_input_tokens_returns_zero(self, tmp_path: Path) -> None: + payload = _complete_snake_payload() + payload.pop("input_tokens") + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=payload, + triggering_seq=1, + ) + assert result == 0 + + def test_missing_output_tokens_returns_zero(self, tmp_path: Path) -> None: + payload = _complete_snake_payload() + payload.pop("output_tokens") + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=payload, + triggering_seq=1, + ) + assert result == 0 + + def test_returns_zero_writes_no_row(self, tmp_path: Path) -> None: + capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload={"input_tokens": 10, "output_tokens": 5}, + triggering_seq=1, + ) + assert _rows(tmp_path) == [] + + +class TestCaptureIdempotent: + def test_second_call_does_not_write_second_row(self, tmp_path: Path) -> None: + capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=1, + ) + capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=2, + ) + rows = _rows(tmp_path) + assert len(rows) == 1 + + +class TestCaptureErrorsAreSwallowed: + def test_broken_store_logs_error_and_returns_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + def boom(self, rows): + raise RuntimeError("disk full") + + monkeypatch.setattr(UsageStore, "append", boom) + + result = capture_usage_cursor( + thirdeye_home=tmp_path, + session_id=SESSION_ID, + payload=_complete_snake_payload(), + triggering_seq=1, + ) + assert result is None + + log = usage_log_path(tmp_path) + assert log.exists() + entry = json.loads(log.read_text().strip().splitlines()[-1]) + assert entry["platform"] == "cursor" + assert entry["phase"] == "extract_usage" + assert entry["session_id"] == SESSION_ID + assert entry["error_class"] == "RuntimeError" From 7717e5c3ca5d0dde4412c7afa84545855a94551e Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sat, 30 May 2026 17:46:09 -0700 Subject: [PATCH 4/6] Add Cursor platform install/uninstall and CLI wiring Implements CursorPlatform with idempotent install of all 14 traced events into ~/.cursor/hooks.json, basename-based dedup so pipx upgrades don't double-register, and uninstall that preserves the file plus unrelated hooks. Wires --cursor into thirdeye add/remove. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/thirdeye/commands/add.py | 5 +- src/thirdeye/platforms/cursor/install.py | 76 ++++++++ tests/test_add_command.py | 49 +++++ tests/test_cursor_install.py | 219 +++++++++++++++++++++++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 src/thirdeye/platforms/cursor/install.py create mode 100644 tests/test_cursor_install.py diff --git a/src/thirdeye/commands/add.py b/src/thirdeye/commands/add.py index cca4bee..e02f54c 100644 --- a/src/thirdeye/commands/add.py +++ b/src/thirdeye/commands/add.py @@ -5,16 +5,19 @@ from thirdeye.platforms.base import Platform from thirdeye.platforms.claude.install import ClaudePlatform from thirdeye.platforms.codex.install import CodexPlatform +from thirdeye.platforms.cursor.install import CursorPlatform from thirdeye.platforms.gemini.install import GeminiPlatform PLATFORMS: dict[str, type[Platform]] = { "claude": ClaudePlatform, "gemini": GeminiPlatform, "codex": CodexPlatform, + "cursor": CursorPlatform, } def _platform_options(fn): + fn = click.option("--cursor", "platform_flag", flag_value="cursor", help="Cursor.")(fn) fn = click.option("--codex", "platform_flag", flag_value="codex", help="Codex CLI.")(fn) fn = click.option("--gemini", "platform_flag", flag_value="gemini", help="Gemini CLI.")(fn) fn = click.option("--claude", "platform_flag", flag_value="claude", help="Claude Code.")(fn) @@ -23,7 +26,7 @@ def _platform_options(fn): def _resolve_platform(platform_flag: str | None) -> Platform: if not platform_flag: - raise click.UsageError("Pick a platform: --claude, --gemini, --codex") + raise click.UsageError("Pick a platform: --claude, --gemini, --codex, --cursor") return PLATFORMS[platform_flag]() diff --git a/src/thirdeye/platforms/cursor/install.py b/src/thirdeye/platforms/cursor/install.py new file mode 100644 index 0000000..610a531 --- /dev/null +++ b/src/thirdeye/platforms/cursor/install.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +from thirdeye.platforms.base import Platform +from thirdeye.platforms.cursor.constants import ( + DISPLAY_NAME, + HOOK_BIN_NAME, + HOOK_TIMEOUT_S, + HOOKS_FILE, + PLATFORM_NAME, + TRACED_EVENTS, +) + + +def _load(path: Path) -> dict: + if not path.exists(): + return {"version": 1, "hooks": {}} + try: + data = json.loads(path.read_text()) + if not isinstance(data, dict): + return {"version": 1, "hooks": {}} + data.setdefault("version", 1) + data.setdefault("hooks", {}) + return data + except (json.JSONDecodeError, OSError): + return {"version": 1, "hooks": {}} + + +def _save(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + + +def _resolve_command() -> str: + return shutil.which(HOOK_BIN_NAME) or HOOK_BIN_NAME + + +def _is_our_entry(entry: dict) -> bool: + cmd = entry.get("command", "") + return Path(cmd).name == HOOK_BIN_NAME + + +class CursorPlatform(Platform): + name = PLATFORM_NAME + display_name = DISPLAY_NAME + + def __init__(self, hooks_file: Path | None = None) -> None: + self._hooks_file = hooks_file or HOOKS_FILE + + def install(self) -> None: + data = _load(self._hooks_file) + hooks = data["hooks"] + cmd = _resolve_command() + for event in TRACED_EVENTS: + entries = hooks.setdefault(event, []) + if not any(_is_our_entry(e) for e in entries): + entries.append( + {"type": "command", "command": cmd, "timeout": HOOK_TIMEOUT_S} + ) + _save(self._hooks_file, data) + + def uninstall(self) -> None: + if not self._hooks_file.exists(): + return + data = _load(self._hooks_file) + hooks = data.get("hooks", {}) + for event in list(hooks.keys()): + entries = [e for e in hooks[event] if not _is_our_entry(e)] + if entries: + hooks[event] = entries + else: + del hooks[event] + _save(self._hooks_file, data) diff --git a/tests/test_add_command.py b/tests/test_add_command.py index 2dd54c3..e19b0cb 100644 --- a/tests/test_add_command.py +++ b/tests/test_add_command.py @@ -9,6 +9,7 @@ from thirdeye.commands.add import PLATFORMS from thirdeye.platforms.claude.install import ClaudePlatform from thirdeye.platforms.codex.install import CodexPlatform +from thirdeye.platforms.cursor.install import CursorPlatform from thirdeye.platforms.gemini.install import GeminiPlatform # -- command registration ------------------------------------------------------ @@ -457,6 +458,54 @@ def test_remove_codex_removes_notify(tmp_path: Path, monkeypatch): # -- claude regression --------------------------------------------------------- +# -- cursor: help, dispatch, PLATFORMS dict ------------------------------------ + + +def test_add_help_mentions_cursor(): + r = CliRunner().invoke(main, ["add", "--help"]) + assert r.exit_code == 0 + assert "--cursor" in r.output + + +def test_remove_help_mentions_cursor(): + r = CliRunner().invoke(main, ["remove", "--help"]) + assert r.exit_code == 0 + assert "--cursor" in r.output + + +def test_platforms_dict_has_cursor(): + assert "cursor" in PLATFORMS + assert PLATFORMS["cursor"] is CursorPlatform + + +def test_add_cursor_dispatches_to_cursor_platform(monkeypatch): + from unittest.mock import MagicMock + + mock_platform = MagicMock() + mock_platform.display_name = "Cursor" + mock_cls = MagicMock(return_value=mock_platform) + + monkeypatch.setitem(PLATFORMS, "cursor", mock_cls) + r = CliRunner().invoke(main, ["add", "--cursor"]) + assert r.exit_code == 0, r.output + mock_cls.assert_called_once() + mock_platform.install.assert_called_once() + + +def test_remove_cursor_dispatches_to_cursor_platform(monkeypatch): + from unittest.mock import MagicMock + + mock_platform = MagicMock() + mock_platform.display_name = "Cursor" + mock_cls = MagicMock(return_value=mock_platform) + + monkeypatch.setitem(PLATFORMS, "cursor", mock_cls) + r = CliRunner().invoke(main, ["remove", "--cursor"]) + assert r.exit_code == 0, r.output + mock_cls.assert_called_once() + mock_platform.uninstall.assert_called_once() + + def test_add_claude_still_works(tmp_path: Path, monkeypatch): """Regression: --claude must still work after adding new platforms.""" settings = tmp_path / "settings.json" diff --git a/tests/test_cursor_install.py b/tests/test_cursor_install.py new file mode 100644 index 0000000..a6acd74 --- /dev/null +++ b/tests/test_cursor_install.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from thirdeye.platforms.cursor.constants import ( + HOOK_BIN_NAME, + HOOK_TIMEOUT_S, + TRACED_EVENTS, +) +from thirdeye.platforms.cursor.install import CursorPlatform + + +class TestCursorPlatformAttributes: + def test_name_is_cursor(self): + p = CursorPlatform(hooks_file=Path("/fake/hooks.json")) + assert p.name == "cursor" + + def test_display_name(self): + p = CursorPlatform(hooks_file=Path("/fake/hooks.json")) + assert p.display_name == "Cursor" + + def test_is_platform_subclass(self): + from thirdeye.platforms.base import Platform + + assert issubclass(CursorPlatform, Platform) + + +class TestInstallFreshFile: + def test_writes_all_traced_events(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + assert set(data["hooks"].keys()) == set(TRACED_EVENTS) + + def test_writes_version_1(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + assert data["version"] == 1 + + def test_creates_parent_dir(self, tmp_path: Path): + hooks_file = tmp_path / "nested" / "deeper" / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + assert hooks_file.exists() + + def test_each_event_has_one_entry(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + for event, entries in data["hooks"].items(): + assert len(entries) == 1, f"expected 1 entry for {event}" + + def test_command_basename_matches_hook_bin_name(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + for event, entries in data["hooks"].items(): + for entry in entries: + assert Path(entry["command"]).name == HOOK_BIN_NAME + + def test_each_entry_has_command_type_and_timeout(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + for event, entries in data["hooks"].items(): + for entry in entries: + assert entry["type"] == "command" + assert entry["timeout"] == HOOK_TIMEOUT_S + assert isinstance(entry["command"], str) + + +class TestInstallIdempotent: + def test_running_install_twice_does_not_duplicate_entries(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + p = CursorPlatform(hooks_file=hooks_file) + p.install() + p.install() + data = json.loads(hooks_file.read_text()) + for event, entries in data["hooks"].items(): + ours = [e for e in entries if Path(e.get("command", "")).name == HOOK_BIN_NAME] + assert len(ours) == 1, f"expected 1 thirdeye entry for {event}" + + def test_running_install_after_path_change_dedups_by_basename( + self, tmp_path: Path, monkeypatch + ): + hooks_file = tmp_path / "hooks.json" + hooks_file.parent.mkdir(parents=True, exist_ok=True) + hooks_file.write_text( + json.dumps( + { + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": f"/old/path/{HOOK_BIN_NAME}", + "timeout": HOOK_TIMEOUT_S, + } + ] + }, + } + ) + ) + monkeypatch.setattr( + "thirdeye.platforms.cursor.install.shutil.which", + lambda _: f"/new/path/{HOOK_BIN_NAME}", + ) + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + ours = [ + e + for e in data["hooks"]["sessionStart"] + if Path(e.get("command", "")).name == HOOK_BIN_NAME + ] + assert len(ours) == 1 + + +class TestInstallPreservesExistingHooks: + def test_preserves_unrelated_hook_under_same_event(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + hooks_file.write_text( + json.dumps( + { + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "/path/to/arize-hook-cursor", + "timeout": 30, + } + ] + }, + } + ) + ) + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + cmds = [e["command"] for e in data["hooks"]["sessionStart"]] + assert "/path/to/arize-hook-cursor" in cmds + assert any(Path(c).name == HOOK_BIN_NAME for c in cmds) + + def test_preserves_unknown_top_level_keys(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + hooks_file.write_text( + json.dumps({"version": 1, "hooks": {}, "custom": "x"}) + ) + CursorPlatform(hooks_file=hooks_file).install() + data = json.loads(hooks_file.read_text()) + assert data["custom"] == "x" + + +class TestUninstallFreshState: + def test_uninstall_missing_file_is_noop(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + CursorPlatform(hooks_file=hooks_file).uninstall() + assert not hooks_file.exists() + + def test_uninstall_empty_hooks_dict_is_noop(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + hooks_file.write_text(json.dumps({"version": 1, "hooks": {}})) + CursorPlatform(hooks_file=hooks_file).uninstall() + data = json.loads(hooks_file.read_text()) + assert data == {"version": 1, "hooks": {}} + + +class TestUninstallRemovesOnlyOurEntries: + def test_removes_only_thirdeye_entries(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + hooks_file.write_text( + json.dumps( + { + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": f"/usr/local/bin/{HOOK_BIN_NAME}", + "timeout": HOOK_TIMEOUT_S, + }, + { + "type": "command", + "command": "/path/to/arize-hook-cursor", + "timeout": 30, + }, + ] + }, + } + ) + ) + CursorPlatform(hooks_file=hooks_file).uninstall() + data = json.loads(hooks_file.read_text()) + cmds = [e["command"] for e in data["hooks"]["sessionStart"]] + assert "/path/to/arize-hook-cursor" in cmds + assert not any(Path(c).name == HOOK_BIN_NAME for c in cmds) + + def test_collapses_empty_event_arrays(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + p = CursorPlatform(hooks_file=hooks_file) + p.install() + p.uninstall() + data = json.loads(hooks_file.read_text()) + assert data["hooks"] == {} + + def test_preserves_version_field(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + p = CursorPlatform(hooks_file=hooks_file) + p.install() + p.uninstall() + data = json.loads(hooks_file.read_text()) + assert data["version"] == 1 + + def test_does_not_delete_file(self, tmp_path: Path): + hooks_file = tmp_path / "hooks.json" + p = CursorPlatform(hooks_file=hooks_file) + p.install() + p.uninstall() + assert hooks_file.exists() From c6a8b8ba1ef2d3c283ccd78b3d32823b0d36d217 Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sat, 30 May 2026 17:56:19 -0700 Subject: [PATCH 5/6] Add Cursor multiplexed hook dispatcher Registers thirdeye-cursor-hook as a single CLI entrypoint that dispatches on hook_event_name / hookEventName, translating each of the 14 traced Cursor events into thirdeye events (or a usage capture for stop / sessionEnd). Always prints a permissive JSON response to sys.__stdout__ and exits 0, never gating Cursor execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 1 + src/thirdeye/platforms/cursor/hook.py | 239 +++++++++++ tests/test_cursor_hook.py | 578 ++++++++++++++++++++++++++ 3 files changed, 818 insertions(+) create mode 100644 src/thirdeye/platforms/cursor/hook.py create mode 100644 tests/test_cursor_hook.py diff --git a/pyproject.toml b/pyproject.toml index e101e3d..b1abc77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ thirdeye-gemini-before-model = "thirdeye.platforms.gemini.hooks:before_model" thirdeye-gemini-after-model = "thirdeye.platforms.gemini.hooks:after_model" thirdeye-gemini-before-tool = "thirdeye.platforms.gemini.hooks:before_tool" thirdeye-gemini-after-tool = "thirdeye.platforms.gemini.hooks:after_tool" +thirdeye-cursor-hook = "thirdeye.platforms.cursor.hook:main" [build-system] requires = ["setuptools>=64", "setuptools-scm>=8", "wheel"] diff --git a/src/thirdeye/platforms/cursor/hook.py b/src/thirdeye/platforms/cursor/hook.py new file mode 100644 index 0000000..6b09300 --- /dev/null +++ b/src/thirdeye/platforms/cursor/hook.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Callable + +from thirdeye.config import Config +from thirdeye.meta import read_meta, write_meta +from thirdeye.paths import meta_path, session_dir +from thirdeye.platforms.cursor.constants import DEDICATED_TOOL_NAMES, STRIP_KEYS +from thirdeye.platforms.cursor.usage import capture_usage_cursor +from thirdeye.store import Store +from thirdeye.tags import TagStore, extract_hashtags + +_PLATFORM = "cursor" + + +def _read_stdin() -> dict: + try: + raw = sys.stdin.read() + except OSError: + return {} + if not raw: + return {} + try: + return json.loads(raw) + except json.JSONDecodeError: + return {} + + +def _get_str(p: dict, *keys: str, default: str = "") -> str: + for k in keys: + v = p.get(k) + if v is not None and v != "": + return str(v) + return default + + +def _event_name(p: dict) -> str: + return _get_str(p, "hook_event_name", "hookEventName") + + +def _session_id(p: dict) -> str: + return _get_str(p, "conversation_id", "conversationId") + + +def _cwd(p: dict) -> str: + cwd = p.get("cwd") + if cwd: + return str(cwd) + roots = p.get("workspace_roots") + if isinstance(roots, list) and roots: + return str(roots[0]) + return os.getcwd() + + +def _strip(p: dict) -> dict: + return {k: v for k, v in p.items() if k not in STRIP_KEYS} + + +def _emit(payload: dict, t: str, extra: dict | None = None) -> int | None: + sid = _session_id(payload) + if not sid: + return None + data = _strip(payload) + if extra: + data = {**data, **extra} + return Store(Config.load()).append_event( + session_id=sid, + platform=_PLATFORM, + cwd=_cwd(payload), + t=t, + data=data, + ) + + +def _print_permissive(event: str) -> None: + out = sys.__stdout__ or sys.stdout + try: + if event.startswith("before"): + out.write('{"permission": "allow"}') + else: + out.write('{"continue": true}') + out.flush() + except Exception: + pass + + +def _h_session_start(payload: dict) -> None: + _emit(payload, "session_start") + + +def _h_session_end(payload: dict) -> None: + sid = _session_id(payload) + if not sid: + return + config = Config.load() + store = Store(config) + seq = store.append_event( + session_id=sid, + platform=_PLATFORM, + cwd=_cwd(payload), + t="session_end", + data=_strip(payload), + ) + store.close_session(sid, platform=_PLATFORM) + capture_usage_cursor( + thirdeye_home=config.root, + session_id=sid, + payload=payload, + triggering_seq=seq if seq is not None else 0, + ) + + +def _h_before_submit(payload: dict) -> None: + sid = _session_id(payload) + if not sid: + return + config = Config.load() + seq = Store(config).append_event( + session_id=sid, + platform=_PLATFORM, + cwd=_cwd(payload), + t="user_message", + data=_strip(payload), + ) + try: + prompt = _get_str(payload, "prompt", "input", "text") + tags = extract_hashtags(prompt) + if not tags: + return + sd = session_dir(config.root, _PLATFORM, sid) + ts = TagStore(sd) + for tag in tags: + ts.add(seq, tag, source="auto") + mp = meta_path(sd) + m = read_meta(mp) + if m is not None: + m.tag_count = ts.tagged_seq_count() + write_meta(mp, m) + except Exception: + pass + + +def _h_after_response(payload: dict) -> None: + _emit(payload, "assistant_message") + + +def _h_before_shell(payload: dict) -> None: + _emit(payload, "tool_call", {"tool_name": "shell"}) + + +def _h_after_shell(payload: dict) -> None: + _emit(payload, "tool_result", {"tool_name": "shell"}) + + +def _h_before_mcp(payload: dict) -> None: + name = _get_str(payload, "tool_name", "toolName", "name", default="mcp") + _emit(payload, "tool_call", {"tool_name": name}) + + +def _h_after_mcp(payload: dict) -> None: + name = _get_str(payload, "tool_name", "toolName", "name", default="mcp") + _emit(payload, "tool_result", {"tool_name": name}) + + +def _h_before_read_file(payload: dict) -> None: + _emit(payload, "tool_call", {"tool_name": "read_file"}) + + +def _h_after_file_edit(payload: dict) -> None: + _emit(payload, "tool_result", {"tool_name": "edit_file"}) + + +def _h_before_tab_read(payload: dict) -> None: + _emit(payload, "tool_call", {"tool_name": "tab_read_file"}) + + +def _h_after_tab_edit(payload: dict) -> None: + _emit(payload, "tool_result", {"tool_name": "tab_edit_file"}) + + +def _h_post_tool_use(payload: dict) -> None: + name = _get_str(payload, "tool_name", "toolName", "name", "tool") + if name and name.lower() in DEDICATED_TOOL_NAMES: + return + if not name: + name = "unknown" + _emit(payload, "tool_result", {"tool_name": name}) + + +def _h_stop(payload: dict) -> None: + sid = _session_id(payload) + if not sid: + return + config = Config.load() + # TODO: Store does not expose a max_seq(session_id) helper. Pass 0 until + # such an API is added rather than introducing a new Store method here. + triggering_seq = 0 + capture_usage_cursor( + thirdeye_home=config.root, + session_id=sid, + payload=payload, + triggering_seq=triggering_seq, + ) + + +_HANDLERS: dict[str, Callable[[dict], None]] = { + "sessionStart": _h_session_start, + "sessionEnd": _h_session_end, + "beforeSubmitPrompt": _h_before_submit, + "afterAgentResponse": _h_after_response, + "beforeShellExecution": _h_before_shell, + "afterShellExecution": _h_after_shell, + "beforeMCPExecution": _h_before_mcp, + "afterMCPExecution": _h_after_mcp, + "beforeReadFile": _h_before_read_file, + "afterFileEdit": _h_after_file_edit, + "beforeTabFileRead": _h_before_tab_read, + "afterTabFileEdit": _h_after_tab_edit, + "postToolUse": _h_post_tool_use, + "stop": _h_stop, +} + + +def main() -> int: + event = "" + try: + payload = _read_stdin() + event = _event_name(payload) + handler = _HANDLERS.get(event) + if handler is not None: + handler(payload) + except Exception: + pass + finally: + _print_permissive(event) + return 0 diff --git a/tests/test_cursor_hook.py b/tests/test_cursor_hook.py new file mode 100644 index 0000000..9ca21d3 --- /dev/null +++ b/tests/test_cursor_hook.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from thirdeye.config import Config +from thirdeye.paths import session_dir, tags_path +from thirdeye.platforms.cursor import hook +from thirdeye.store import Store + + +@pytest.fixture +def env(monkeypatch, tmp_path: Path): + monkeypatch.setenv("THIRDEYE_HOME", str(tmp_path)) + return tmp_path + + +def _stdin(monkeypatch, payload: dict) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + + +# -- _read_stdin --------------------------------------------------------------- + + +class TestReadStdin: + def test_valid_json(self, monkeypatch): + _stdin(monkeypatch, {"conversation_id": "abc", "cwd": "/p"}) + assert hook._read_stdin() == {"conversation_id": "abc", "cwd": "/p"} + + def test_empty_stdin_returns_empty_dict(self, monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("")) + assert hook._read_stdin() == {} + + def test_invalid_json_returns_empty_dict(self, monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("not json")) + assert hook._read_stdin() == {} + + def test_io_error_returns_empty_dict(self, monkeypatch): + class BrokenStdin: + def read(self): + raise OSError("broken pipe") + + monkeypatch.setattr("sys.stdin", BrokenStdin()) + assert hook._read_stdin() == {} + + +# -- payload helpers ----------------------------------------------------------- + + +class TestPayloadHelpers: + def test_event_name_reads_snake_case(self): + assert hook._event_name({"hook_event_name": "sessionStart"}) == "sessionStart" + + def test_event_name_reads_camel_case(self): + assert hook._event_name({"hookEventName": "sessionStart"}) == "sessionStart" + + def test_event_name_prefers_snake_when_both_present(self): + assert hook._event_name({"hook_event_name": "snake", "hookEventName": "camel"}) == "snake" + + def test_session_id_reads_both_cases(self): + assert hook._session_id({"conversation_id": "s1"}) == "s1" + assert hook._session_id({"conversationId": "s2"}) == "s2" + + def test_cwd_fallback_chain(self, monkeypatch, tmp_path: Path): + assert hook._cwd({"cwd": "/explicit"}) == "/explicit" + assert hook._cwd({"workspace_roots": ["/root/a", "/root/b"]}) == "/root/a" + monkeypatch.chdir(tmp_path) + assert hook._cwd({}) == str(tmp_path) + + def test_strip_removes_routing_and_pii_keys(self): + payload = { + "conversation_id": "abc", + "conversationId": "abc", + "workspace_roots": ["/r"], + "transcript_path": "/x", + "user_email": "u@example.com", + "hook_event_name": "sessionStart", + "hookEventName": "sessionStart", + "prompt": "hi", + } + assert hook._strip(payload) == {"prompt": "hi"} + + +# -- _print_permissive --------------------------------------------------------- + + +class TestPrintPermissive: + def test_before_events_get_permission_allow(self, capfd): + hook._print_permissive("beforeSubmitPrompt") + out = capfd.readouterr().out + assert '"permission": "allow"' in out + + def test_other_events_get_continue_true(self, capfd): + hook._print_permissive("afterAgentResponse") + out = capfd.readouterr().out + assert '"continue": true' in out + + def test_main_prints_permissive_even_when_handler_raises(self, monkeypatch, env: Path, capfd): + def boom(payload): + raise RuntimeError("nope") + + monkeypatch.setitem(hook._HANDLERS, "sessionStart", boom) + _stdin( + monkeypatch, + {"hook_event_name": "sessionStart", "conversation_id": "s1"}, + ) + rc = hook.main() + assert rc == 0 + out = capfd.readouterr().out + # sessionStart doesn't start with "before" → continue + assert '"continue": true' in out + + +# -- dispatch ------------------------------------------------------------------ + + +class TestDispatch: + def _events(self, sid: str) -> list[dict]: + return list(Store(Config.load()).reader(sid).iter_events()) + + def test_session_start(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "sessionStart", + "conversation_id": "s1", + "cwd": "/p", + "model": "composer-1", + }, + ) + hook.main() + events = self._events("s1") + assert len(events) == 1 + assert events[0]["t"] == "session_start" + assert events[0]["data"]["model"] == "composer-1" + assert "conversation_id" not in events[0]["data"] + assert "hook_event_name" not in events[0]["data"] + + def test_before_submit_prompt(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeSubmitPrompt", + "conversation_id": "s1", + "cwd": "/p", + "prompt": "hello", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "user_message" + assert events[0]["data"]["prompt"] == "hello" + + def test_after_agent_response(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "afterAgentResponse", + "conversation_id": "s1", + "cwd": "/p", + "response": "done", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "assistant_message" + assert events[0]["data"]["response"] == "done" + + def test_before_shell(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeShellExecution", + "conversation_id": "s1", + "cwd": "/p", + "command": "ls", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_call" + assert events[0]["data"]["tool_name"] == "shell" + assert events[0]["data"]["command"] == "ls" + + def test_after_shell(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "afterShellExecution", + "conversation_id": "s1", + "cwd": "/p", + "exit_code": 0, + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "shell" + + def test_before_mcp(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeMCPExecution", + "conversation_id": "s1", + "cwd": "/p", + "tool_name": "my_mcp_tool", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_call" + assert events[0]["data"]["tool_name"] == "my_mcp_tool" + + def test_before_mcp_default_name(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeMCPExecution", + "conversation_id": "s1", + "cwd": "/p", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["data"]["tool_name"] == "mcp" + + def test_after_mcp(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "afterMCPExecution", + "conversation_id": "s1", + "cwd": "/p", + "toolName": "some_tool", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "some_tool" + + def test_before_read_file(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeReadFile", + "conversation_id": "s1", + "cwd": "/p", + "file_path": "x.py", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_call" + assert events[0]["data"]["tool_name"] == "read_file" + + def test_after_file_edit(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "afterFileEdit", + "conversation_id": "s1", + "cwd": "/p", + "file_path": "x.py", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "edit_file" + + def test_before_tab_read(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "beforeTabFileRead", + "conversation_id": "s1", + "cwd": "/p", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_call" + assert events[0]["data"]["tool_name"] == "tab_read_file" + + def test_after_tab_edit(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "afterTabFileEdit", + "conversation_id": "s1", + "cwd": "/p", + }, + ) + hook.main() + events = self._events("s1") + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "tab_edit_file" + + +# -- missing session id -------------------------------------------------------- + + +class TestMissingSessionId: + @pytest.mark.parametrize( + "event", + [ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "afterAgentResponse", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "beforeTabFileRead", + "afterTabFileEdit", + "postToolUse", + "stop", + ], + ) + def test_no_event_appended(self, monkeypatch, env: Path, event: str): + _stdin(monkeypatch, {"hook_event_name": event, "cwd": "/p"}) + rc = hook.main() + assert rc == 0 + assert list(Store(Config.load()).list_sessions()) == [] + + +# -- beforeSubmitPrompt autotag ------------------------------------------------ + + +class TestBeforeSubmitAutotag: + def _tags_lines(self, env: Path, sid: str) -> list[dict]: + path = tags_path(session_dir(env, "cursor", sid)) + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + def test_hashtags_in_prompt_become_tags(self, monkeypatch, env: Path): + # Open the session first so meta exists. + _stdin( + monkeypatch, + {"hook_event_name": "sessionStart", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + + _stdin( + monkeypatch, + { + "hook_event_name": "beforeSubmitPrompt", + "conversation_id": "s1", + "cwd": "/p", + "prompt": "fix #bug in #parser", + }, + ) + hook.main() + + lines = self._tags_lines(env, "s1") + tags = {line["tag"] for line in lines} + assert tags == {"bug", "parser"} + for line in lines: + assert line["op"] == "add" + assert line["source"] == "auto" + + m = Store(Config.load()).get_meta("s1") + assert m.tag_count == 1 + + +# -- postToolUse dedup --------------------------------------------------------- + + +class TestPostToolUseDedup: + def _events(self, sid: str) -> list[dict]: + return list(Store(Config.load()).reader(sid).iter_events()) + + def test_skips_dedicated_tool_names(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "postToolUse", + "conversation_id": "s1", + "cwd": "/p", + "tool_name": "shell", + }, + ) + hook.main() + assert list(Store(Config.load()).list_sessions()) == [] + + def test_lowercases_for_comparison(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "postToolUse", + "conversation_id": "s1", + "cwd": "/p", + "tool_name": "Shell", + }, + ) + hook.main() + assert list(Store(Config.load()).list_sessions()) == [] + + def test_non_dedicated_tool_writes_event(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "postToolUse", + "conversation_id": "s1", + "cwd": "/p", + "tool_name": "my_custom_tool", + }, + ) + hook.main() + events = self._events("s1") + assert len(events) == 1 + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "my_custom_tool" + + def test_unknown_tool_name_falls_back_to_unknown(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "postToolUse", + "conversation_id": "s1", + "cwd": "/p", + }, + ) + hook.main() + events = self._events("s1") + assert len(events) == 1 + assert events[0]["t"] == "tool_result" + assert events[0]["data"]["tool_name"] == "unknown" + + +# -- stop ---------------------------------------------------------------------- + + +class TestStop: + def test_stop_does_not_append_event(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + { + "hook_event_name": "stop", + "conversation_id": "s1", + "cwd": "/p", + "model": "composer-1", + "input_tokens": 10, + "output_tokens": 5, + }, + ) + hook.main() + # No event was appended to the store. + assert list(Store(Config.load()).list_sessions()) == [] + + def test_stop_calls_capture_usage_cursor(self, monkeypatch, env: Path): + calls: list[dict] = [] + + def fake(*, thirdeye_home, session_id, payload, triggering_seq): + calls.append( + { + "thirdeye_home": thirdeye_home, + "session_id": session_id, + "payload": payload, + "triggering_seq": triggering_seq, + } + ) + return 1 + + monkeypatch.setattr(hook, "capture_usage_cursor", fake) + payload = { + "hook_event_name": "stop", + "conversation_id": "s1", + "cwd": "/p", + "model": "composer-1", + "input_tokens": 10, + "output_tokens": 5, + } + _stdin(monkeypatch, payload) + hook.main() + assert len(calls) == 1 + assert calls[0]["session_id"] == "s1" + assert calls[0]["payload"]["model"] == "composer-1" + assert "triggering_seq" in calls[0] + + def test_stop_with_missing_conversation_id_is_noop(self, monkeypatch, env: Path): + calls: list = [] + monkeypatch.setattr( + hook, + "capture_usage_cursor", + lambda **kw: calls.append(kw) or 0, + ) + _stdin(monkeypatch, {"hook_event_name": "stop", "cwd": "/p"}) + hook.main() + assert calls == [] + + +# -- sessionEnd ---------------------------------------------------------------- + + +class TestSessionEnd: + def test_appends_session_end_event(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + {"hook_event_name": "sessionStart", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + _stdin( + monkeypatch, + {"hook_event_name": "sessionEnd", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + events = list(Store(Config.load()).reader("s1").iter_events()) + assert events[-1]["t"] == "session_end" + + def test_closes_session(self, monkeypatch, env: Path): + _stdin( + monkeypatch, + {"hook_event_name": "sessionStart", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + _stdin( + monkeypatch, + {"hook_event_name": "sessionEnd", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + m = next(Store(Config.load()).list_sessions()) + assert m.status == "closed" + assert m.ended_at is not None + + def test_invokes_usage_capture_as_fallback(self, monkeypatch, env: Path): + calls: list = [] + monkeypatch.setattr( + hook, + "capture_usage_cursor", + lambda **kw: calls.append(kw) or 0, + ) + _stdin( + monkeypatch, + {"hook_event_name": "sessionStart", "conversation_id": "s1", "cwd": "/p"}, + ) + hook.main() + _stdin( + monkeypatch, + { + "hook_event_name": "sessionEnd", + "conversation_id": "s1", + "cwd": "/p", + "model": "composer-1", + "input_tokens": 10, + "output_tokens": 5, + }, + ) + hook.main() + assert len(calls) == 1 + assert calls[0]["session_id"] == "s1" + # triggering_seq comes from the session_end seq which is >= 0. + assert calls[0]["triggering_seq"] >= 0 + + +# -- unknown event ------------------------------------------------------------- + + +class TestUnknownEvent: + def test_unknown_event_name_is_noop(self, monkeypatch, env: Path, capfd): + _stdin( + monkeypatch, + {"hook_event_name": "preCompact", "conversation_id": "s1", "cwd": "/p"}, + ) + rc = hook.main() + assert rc == 0 + assert list(Store(Config.load()).list_sessions()) == [] + out = capfd.readouterr().out + assert '"continue": true' in out From eb950f186232207dc250764545f266f2dae6894b Mon Sep 17 00:00:00 2001 From: Duncan McKinnon Date: Sun, 31 May 2026 17:43:53 -0700 Subject: [PATCH 6/6] formatting --- src/thirdeye/platforms/cursor/install.py | 4 +- src/thirdeye/skills/use-thirdeye/SKILL.md | 15 ++- .../references/session-efficiency-review.md | 122 ++++++++++++++++++ tests/test_cursor_constants.py | 21 ++- tests/test_cursor_install.py | 4 +- tests/test_cursor_usage.py | 10 +- 6 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 src/thirdeye/skills/use-thirdeye/references/session-efficiency-review.md diff --git a/src/thirdeye/platforms/cursor/install.py b/src/thirdeye/platforms/cursor/install.py index 610a531..9392499 100644 --- a/src/thirdeye/platforms/cursor/install.py +++ b/src/thirdeye/platforms/cursor/install.py @@ -57,9 +57,7 @@ def install(self) -> None: for event in TRACED_EVENTS: entries = hooks.setdefault(event, []) if not any(_is_our_entry(e) for e in entries): - entries.append( - {"type": "command", "command": cmd, "timeout": HOOK_TIMEOUT_S} - ) + entries.append({"type": "command", "command": cmd, "timeout": HOOK_TIMEOUT_S}) _save(self._hooks_file, data) def uninstall(self) -> None: diff --git a/src/thirdeye/skills/use-thirdeye/SKILL.md b/src/thirdeye/skills/use-thirdeye/SKILL.md index 56ac21f..d09f51e 100644 --- a/src/thirdeye/skills/use-thirdeye/SKILL.md +++ b/src/thirdeye/skills/use-thirdeye/SKILL.md @@ -1,6 +1,6 @@ --- name: use-thirdeye -description: Use when an agent needs to inspect, search, or evaluate past agent sessions captured by the thirdeye CLI — including debugging tool calls, analyzing token usage, retrieving session events, and running evaluations across recorded traces. +description: Use when an agent needs to inspect, search, or evaluate past agent sessions captured by the thirdeye CLI — including debugging tool calls, analyzing token usage, reviewing session efficiency to suggest skill improvements, retrieving session events, and running evaluations across recorded traces. --- ## Overview @@ -74,6 +74,19 @@ thirdeye stats thirdeye stats --json ``` +## Reviewing session efficiency + +When asked to improve agent performance, reduce token usage, or suggest new skills based on +recorded behavior, use the workflow in +[session-efficiency-review.md](references/session-efficiency-review.md). It covers population +selection, tool-mix analysis, red-flag thresholds, and how to turn findings into skill or +plan updates. + +```bash +thirdeye list --json --cwd "$PWD" --since 2026-05-01 | jq 'select(.event_count > 80)' +thirdeye events --json | jq -r 'select(.t == "tool_call") | .data.tool_name' | sort | uniq -c +``` + ## Running evaluations When grading agent behavior against a rubric — accuracy of edits, adherence to instructions, diff --git a/src/thirdeye/skills/use-thirdeye/references/session-efficiency-review.md b/src/thirdeye/skills/use-thirdeye/references/session-efficiency-review.md new file mode 100644 index 0000000..8de3ec6 --- /dev/null +++ b/src/thirdeye/skills/use-thirdeye/references/session-efficiency-review.md @@ -0,0 +1,122 @@ +# Session efficiency review + +Analyze recorded sessions to find wasted tool calls, redundant exploration, and skill gaps. +Use this when asked to improve agent performance, token usage, or suggest new skills. + +## Quick population scan + +```bash +# High-activity sessions in a repo (event count ≈ cost proxy) +thirdeye list --json --cwd "$PWD" --since 2026-05-01 \ + | jq 'select(.event_count > 80) | {id: .session_id[0:8], events: .event_count, cwd: .cwd}' + +# Workbench task sessions only +thirdeye list --json --since 2026-05-01 \ + | jq 'select(.cwd | test("/\\.workbench/.+/task-")) | .session_id' -r +``` + +## Event schema note + +Claude Code events use `t` (not `type`) and `data.tool_name` (not `data.name`): + +```bash +thirdeye events --json | jq -r 'select(.t == "tool_call") | .data.tool_name' \ + | sort | uniq -c | sort -rn +``` + +## Tool-mix analysis + +Count tool usage and bash sub-patterns across a session: + +```bash +thirdeye events --json | python3 - <<'PY' +import json, sys, re, collections +tools, bash = collections.Counter(), collections.Counter() +reads = collections.Counter() +for line in sys.stdin: + e = json.loads(line) + if e.get("t") != "tool_call": continue + name = e["data"].get("tool_name", "?") + tools[name] += 1 + inp = e["data"].get("tool_input", {}) + if name == "Read": + reads[inp.get("file_path", "")] += 1 + elif name == "Bash": + cmd = inp.get("command", "") + if re.search(r"\bfind\b|\bls\b|\btree\b", cmd): bash["filesystem"] += 1 + elif re.search(r"\brg\b|\bgrep\b", cmd): bash["grep_via_bash"] += 1 + elif re.search(r"\bpytest\b|\bnpm test\b", cmd): bash["test"] += 1 + elif re.search(r"\bgit\b", cmd): bash["git"] += 1 + else: bash["other"] += 1 +print("tools:", dict(tools.most_common(10))) +print("bash:", dict(bash)) +print("re-reads:", [(p, c) for p, c in reads.most_common(8) if c > 1]) +PY +``` + +## Red flags (what to look for) + +| Signal | Threshold | Likely cause | Skill/fix | +|--------|-----------|--------------|-----------| +| `grep_via_bash` > 10 | High | Agent uses Bash instead of Grep | Add exploration guidance to plan conventions | +| Same file Read ≥ 3× | High | Lost context or no note-taking | Task description should cite line ranges; agent should cite, not re-read | +| `filesystem` (find/ls) > 5 | Medium | No codebase map | Add module map to plan Context or repo skill | +| 0 Skill invocations on complex task | Medium | Skill not triggered | Improve skill description; `@`-mention in prompt | +| Tool/event ratio > 0.75 | High | Chatty loop (read-edit-test cycles) | Tighter task scope; `--max-retries` may be too high | +| Large tool_result payloads | Check token-use-analysis.md | Unpaginated reads or MCP dumps | Use `head_limit`, `offset`, or targeted Grep | + +## Compare sessions with vs without skills + +```bash +thirdeye list --json --since 2026-05-01 | python3 - <<'PY' +import json, sys, subprocess, collections +sessions = [json.loads(l) for l in sys.stdin if l.strip()] +skill_used, no_skill = [], [] +for s in sessions: + if s["event_count"] < 30: continue + out = subprocess.check_output(["thirdeye", "events", s["session_id"], "--json"], text=True) + skill = sum(1 for l in out.splitlines() if '"tool_name": "Skill"' in l) + tools = sum(1 for l in out.splitlines() if '"t": "tool_call"' in l) + bucket = skill_used if skill else no_skill + bucket.append((s["event_count"], tools)) +if skill_used: + print(f"with Skill: n={len(skill_used)} avg_events={sum(x[0] for x in skill_used)/len(skill_used):.0f}") +if no_skill: + print(f"without Skill: n={len(no_skill)} avg_events={sum(x[0] for x in no_skill)/len(no_skill):.0f}") +PY +``` + +Note: sessions that invoke skills tend to be longer tasks (selection bias). Compare within the same task type (e.g. all `project-conventions/task-*`) rather than globally. + +## Rubrics for skill suggestions + +After scanning 5–10 representative sessions, ask: + +1. **Repeated exploration** — Did multiple sessions run the same `find`/`grep`/`ls` commands? → Encapsulate in a repo navigation skill or plan Context section. +2. **Missing upfront context** — Did agents discover test commands, module layout, or config paths via exploration? → Add to `.workbench/conventions.md` or task `Files:` lines. +3. **Wrong tool choice** — Bash grep vs Grep, find vs Glob, broad Read vs targeted Grep? → Add explicit tool-preference bullets to conventions. +4. **Workbench task overhead** — Do dispatched tasks spend >30% of tool calls on orientation? → Plan task descriptions need more `Files:` and interface specs (see use-workbench skill). +5. **Skill underuse** — Is a relevant skill available but never invoked? → Tighten the skill `description` frontmatter (trigger phrases) or `@`-reference it in the plan Context. + +## Tag findings for iteration + +```bash +thirdeye tag 0 --add efficiency-review,high-reread +thirdeye tag 0 --add efficiency-review,bash-grep-waste +``` + +Filter later: + +```bash +thirdeye list --tag efficiency-review --json +``` + +## Report template + +When presenting findings, structure as: + +1. **Population** — N sessions, date range, repos/task types +2. **Aggregate metrics** — tool mix, avg events/session, re-read count, bash-explore count +3. **Top waste patterns** — 2–3 concrete examples with session ID prefix and what happened +4. **Skill recommendations** — new skills or updates to existing ones, tied to observed patterns +5. **Plan/convention changes** — bullets to add to `.workbench/conventions.md` or plan Context diff --git a/tests/test_cursor_constants.py b/tests/test_cursor_constants.py index 8e09d3d..b73cfa5 100644 --- a/tests/test_cursor_constants.py +++ b/tests/test_cursor_constants.py @@ -23,13 +23,20 @@ def test_under_cursor_dir(self): class TestTracedEvents: def test_contains_all_documented_events(self): expected = { - "sessionStart", "sessionEnd", - "beforeSubmitPrompt", "afterAgentResponse", - "beforeShellExecution", "afterShellExecution", - "beforeMCPExecution", "afterMCPExecution", - "beforeReadFile", "afterFileEdit", - "beforeTabFileRead", "afterTabFileEdit", - "postToolUse", "stop", + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "afterAgentResponse", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "beforeTabFileRead", + "afterTabFileEdit", + "postToolUse", + "stop", } assert set(constants.TRACED_EVENTS) == expected diff --git a/tests/test_cursor_install.py b/tests/test_cursor_install.py index a6acd74..b3925fd 100644 --- a/tests/test_cursor_install.py +++ b/tests/test_cursor_install.py @@ -143,9 +143,7 @@ def test_preserves_unrelated_hook_under_same_event(self, tmp_path: Path): def test_preserves_unknown_top_level_keys(self, tmp_path: Path): hooks_file = tmp_path / "hooks.json" - hooks_file.write_text( - json.dumps({"version": 1, "hooks": {}, "custom": "x"}) - ) + hooks_file.write_text(json.dumps({"version": 1, "hooks": {}, "custom": "x"})) CursorPlatform(hooks_file=hooks_file).install() data = json.loads(hooks_file.read_text()) assert data["custom"] == "x" diff --git a/tests/test_cursor_usage.py b/tests/test_cursor_usage.py index da7e54a..2161715 100644 --- a/tests/test_cursor_usage.py +++ b/tests/test_cursor_usage.py @@ -14,7 +14,6 @@ ) from thirdeye.usage.store import UsageStore - SESSION_ID = "test-conv-123" @@ -76,14 +75,19 @@ def test_empty_string_falls_through(self) -> None: assert _get_int({"input_tokens": "", "inputTokens": 7}, "input_tokens", "inputTokens") == 7 def test_dash_dash_falls_through(self) -> None: - assert _get_int({"input_tokens": "--", "inputTokens": 9}, "input_tokens", "inputTokens") == 9 + assert ( + _get_int({"input_tokens": "--", "inputTokens": 9}, "input_tokens", "inputTokens") == 9 + ) def test_returns_none_when_all_missing(self) -> None: assert _get_int({}, "input_tokens", "inputTokens") is None def test_unparseable_falls_through_then_returns_none(self) -> None: assert _get_int({"input_tokens": "abc"}, "input_tokens") is None - assert _get_int({"input_tokens": "abc", "inputTokens": "xyz"}, "input_tokens", "inputTokens") is None + assert ( + _get_int({"input_tokens": "abc", "inputTokens": "xyz"}, "input_tokens", "inputTokens") + is None + ) class TestCaptureFromCompletePayload: