diff --git a/src/openchronicle/capture/s1_parser.py b/src/openchronicle/capture/s1_parser.py index f8466616..928013a3 100644 --- a/src/openchronicle/capture/s1_parser.py +++ b/src/openchronicle/capture/s1_parser.py @@ -13,6 +13,7 @@ from __future__ import annotations +import os import re from dataclasses import asdict, dataclass from typing import Any @@ -29,7 +30,36 @@ "com.operasoftware.Opera", } +_EDITOR_BUNDLES = { + "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + "com.todesktop.230313mzl4w4u92", # Cursor (Electron) + "com.codeium.windsurf", # Windsurf + "com.cursor.Cursor", # Cursor (alt) +} + +_TERMINAL_BUNDLES = { + "com.apple.Terminal", + "com.googlecode.iterm2", + "dev.warp.Warp-Stable", + "com.alacritty.Alacritty", + "org.alacritty", + "co.zeit.hyper", + "net.kovidgoyal.kitty", +} + +# Map bundle_id → trailing display-name suffix in window titles. +_EDITOR_DISPLAY_SUFFIXES = { + "com.microsoft.VSCode": " - Visual Studio Code", + "com.microsoft.VSCodeInsiders": " - Visual Studio Code - Insiders", + "com.todesktop.230313mzl4w4u92": " - Cursor", + "com.codeium.windsurf": " - Windsurf", + "com.cursor.Cursor": " - Cursor", +} + _URL_RE = re.compile(r"https?://\S+") +# Match [git:branch] or [WSL:distro] or bare [branch] in editor titles. +_BRACKET_BRANCH_RE = re.compile(r"\[(git:|WSL:)?([^\]]+)\]") _EDITABLE_ROLES = {"AXTextField", "AXTextArea", "AXComboBox"} _STATIC_ROLES = {"AXStaticText", "AXWebArea"} @@ -57,7 +87,9 @@ def to_dict(self) -> dict[str, Any]: def enrich(capture: dict[str, Any]) -> None: - """Mutate ``capture`` in place: add ``focused_element`` / ``visible_text`` / ``url``. + """Mutate ``capture`` in place: add ``focused_element`` / ``visible_text`` / ``url`` + plus app-specific fields (``editor_file`` / ``editor_project`` / ``editor_git_branch`` / + ``terminal_cwd``) when the frontmost app is a known editor or terminal. No-op when there is no ``ax_tree`` (e.g. AX unavailable, permission denied). """ @@ -70,12 +102,38 @@ def enrich(capture: dict[str, Any]) -> None: capture["focused_element"] = FocusedElement().to_dict() capture["visible_text"] = "" capture["url"] = None + capture["editor_file"] = None + capture["editor_project"] = None + capture["editor_git_branch"] = None + capture["terminal_cwd"] = None return capture["focused_element"] = _extract_focused_element(app_data).to_dict() capture["visible_text"] = _render_visible_text(app_data) capture["url"] = _extract_url(app_data) + # ── App-specific S1 fields ────────────────────────────────────────── + bundle = (app_data.get("bundle_id") or "").strip() + + title = _get_window_title(app_data, capture) + + capture["editor_file"] = None + capture["editor_project"] = None + capture["editor_git_branch"] = None + capture["terminal_cwd"] = None + + if not title: + return + + if bundle in _EDITOR_BUNDLES: + editor_info = _extract_editor_info(title, bundle) + capture["editor_file"] = editor_info[0] + capture["editor_project"] = editor_info[1] + capture["editor_git_branch"] = editor_info[2] + + if bundle in _TERMINAL_BUNDLES: + capture["terminal_cwd"] = _extract_terminal_cwd(title) + def _frontmost_app(ax_tree: dict[str, Any]) -> dict[str, Any] | None: apps = ax_tree.get("apps") or [] @@ -131,3 +189,119 @@ def _extract_url(app_data: dict[str, Any]) -> str | None: if "." in value and " " not in value: return f"https://{value}" return None + + +# ── App-specific S1 helpers ─────────────────────────────────────────────────── + + +def _get_window_title(app_data: dict[str, Any], capture: dict[str, Any]) -> str: + """Best-effort window title for the frontmost app. + + Prefers ``window_meta.title`` (osascript — faster and avoids AX noise), + falls back to the first focused window title in the AX tree. + """ + wm = capture.get("window_meta") or {} + title = (wm.get("title") or "").strip() + if title: + return title + for win in app_data.get("windows", []): + if win.get("focused"): + return (win.get("title") or "").strip() + return "" + + +def _extract_editor_info( + title: str, bundle_id: str +) -> tuple[str | None, str | None, str | None]: + """Parse an editor window title into ``(file, project, git_branch)``. + + Handles the common VS Code / Cursor / Windsurf title patterns:: + + main.py — project-name - Visual Studio Code + app.ts — website [git:feat/auth] - Cursor + file.rs — project [WSL:Ubuntu] - Visual Studio Code + main.py - Visual Studio Code (no project) + """ + # 1. Strip the trailing " - " suffix. + suffix = _EDITOR_DISPLAY_SUFFIXES.get(bundle_id) + if suffix and title.endswith(suffix): + core = title[: -len(suffix)] + else: + core = title + # Fallback: try each known suffix in case bundle_id didn't match + # but we still have a recognisable title (e.g. a new fork). + for known_suffix in _EDITOR_DISPLAY_SUFFIXES.values(): + if core.endswith(known_suffix): + core = core[: -len(known_suffix)] + break + + core = core.strip() + if not core: + return None, None, None + + # 2. Extract bracket annotation ([git:...], [WSL:...], [main]). + branch: str | None = None + m = _BRACKET_BRANCH_RE.search(core) + if m: + prefix = m.group(1) or "" + branch = prefix + m.group(2) + # Remove the bracket block from core so it doesn't pollute project/filename. + core = (core[: m.start()] + core[m.end() :]).strip() + + # 3. Split on em dash (U+2014) to separate file from project. + if " — " in core: + file_part, _, project_part = core.partition(" — ") + file_part = _clean_file_token(file_part) + project_part = project_part.strip() + return file_part or None, project_part or None, branch + + # 4. No em dash — try ASCII " - " as a weaker separator. + # Only split when both sides look meaningful (avoid splitting + # filenames like "my-app.ts"). When the title is "file - project", + # left is the file and right is the project — same order as em-dash. + if " - " in core: + left, _, right = core.partition(" - ") + if _looks_like_filename(left) and _looks_like_filename(right): + return _clean_file_token(left) or None, _clean_file_token(right) or None, branch + + # 5. No recognisable separator — the entire title is the filename. + file_part = _clean_file_token(core) + return file_part or None, None, branch + + +def _clean_file_token(token: str) -> str: + """Remove noise suffixes like ``(diff)``, ``(read-only)`` from a file token.""" + return re.sub(r"\s*\([^)]*\)\s*$", "", token.strip()).strip() + + +def _looks_like_filename(token: str) -> bool: + """Heuristic: does ``token`` plausibly look like a file or project name?""" + token = token.strip() + return bool(token) and not token.startswith("[") and len(token) >= 2 + + +def _extract_terminal_cwd(title: str) -> str | None: + """Extract the current working directory from a terminal window title. + + Terminal titles vary by emulator and shell config. Common patterns:: + + vim — ~/projects/foo — zsh (iTerm2 default) + ~/projects/foo — zsh — 80x24 + user@host: /var/www (SSH) + Terminal (no path — returns None) + """ + # Split on common title separators and look for the first path-like token. + tokens = re.split(r"\s*[—–\-]\s*", title) + for token in tokens: + token = token.strip() + if not token: + continue + # SSH-style: user@host:/path or user@host:~/path + # (optional whitespace after colon). + ssh_m = re.match(r"^[^@]+@[^:]+:\s*([~/].+)$", token) + if ssh_m: + return os.path.normpath(os.path.expanduser(ssh_m.group(1))) + # Local path: ~/…, /… (no ./ — relative to the daemon CWD, meaningless). + if token.startswith(("/", "~")): + return os.path.normpath(os.path.expanduser(token)) + return None diff --git a/src/openchronicle/mcp/captures.py b/src/openchronicle/mcp/captures.py index 9c56cc94..0803dde6 100644 --- a/src/openchronicle/mcp/captures.py +++ b/src/openchronicle/mcp/captures.py @@ -105,6 +105,11 @@ def _format_response( }, "visible_text": data.get("visible_text") or "", "screenshot_stripped": bool(data.get("screenshot_stripped")), + # App-specific S1 fields (populated for known editor / terminal apps). + "editor_file": data.get("editor_file"), + "editor_project": data.get("editor_project"), + "editor_git_branch": data.get("editor_git_branch"), + "terminal_cwd": data.get("terminal_cwd"), } if include_screenshot and shot.get("image_base64"): out["screenshot_b64"] = shot["image_base64"] diff --git a/src/openchronicle/timeline/aggregator.py b/src/openchronicle/timeline/aggregator.py index 1b765f6c..9abf835c 100644 --- a/src/openchronicle/timeline/aggregator.py +++ b/src/openchronicle/timeline/aggregator.py @@ -143,6 +143,20 @@ def _format_events(parsed: list[tuple[Path, dict]]) -> tuple[str, list[str]]: if url: parts.append(f"(URL: {url})") + # App-specific S1 fields — compact hints for the timeline LLM. + editor_file = data.get("editor_file") + if editor_file: + parts.append(f"(file: {editor_file})") + editor_project = data.get("editor_project") + if editor_project: + parts.append(f"(project: {editor_project})") + editor_branch = data.get("editor_git_branch") + if editor_branch: + parts.append(f"(git: {editor_branch})") + terminal_cwd = data.get("terminal_cwd") + if terminal_cwd: + parts.append(f"(cwd: {terminal_cwd})") + fe = data.get("focused_element") or {} role = str(fe.get("role") or "") if role: diff --git a/tests/test_mcp_tools.py b/tests/test_mcp_tools.py index 545f80d8..1caddbcc 100644 --- a/tests/test_mcp_tools.py +++ b/tests/test_mcp_tools.py @@ -172,3 +172,125 @@ def test_current_context_app_filter(ac_root: Path) -> None: ctx = captures_mod.current_context(app_filter="Safari", headline_limit=5) assert [h["file_stem"] for h in ctx["recent_captures_headline"]] == ["c2"] + + +# ── S1 editor / terminal fields exposed through MCP tools ─────────────────── + + +def _write_capture_json(ac_root: Path, stem: str, capture: dict) -> None: + """Write a capture JSON to the buffer directory with the given stem.""" + import json + + from openchronicle import paths + + buf = paths.capture_buffer_dir() + filepath = buf / f"{stem}.json" + filepath.write_text(json.dumps(capture, ensure_ascii=False)) + + +def test_read_recent_capture_exposes_editor_fields(ac_root: Path) -> None: + """Agent calls read_recent_capture → response includes editor_file etc.""" + _write_capture_json(ac_root, "2026-04-26T14-30-00p08-00", { + "timestamp": "2026-04-26T14:30:00+08:00", + "schema_version": 2, + "window_meta": { + "app_name": "Code", + "title": "main.py — openchronicle [git:main] - Visual Studio Code", + "bundle_id": "com.microsoft.VSCode", + }, + "ax_tree": {}, + "focused_element": { + "role": "AXTextArea", "title": "editor", + "value": "def foo(): pass", "is_editable": True, + "value_length": 16, + }, + "visible_text": "## Code\n### main.py\n- [AXTextArea] editor — def foo(): pass", + "url": None, + "editor_file": "main.py", + "editor_project": "openchronicle", + "editor_git_branch": "git:main", + "terminal_cwd": None, + "screenshot_stripped": True, + }) + + result = captures_mod.read_recent_capture( + at="2026-04-26T14:30:00+08:00", app_name="Code", + ) + assert result is not None, "should find the VS Code capture" + assert result["editor_file"] == "main.py" + assert result["editor_project"] == "openchronicle" + assert result["editor_git_branch"] == "git:main" + assert result["terminal_cwd"] is None + + +def test_read_recent_capture_exposes_terminal_cwd(ac_root: Path) -> None: + """Agent calls read_recent_capture on iTerm2 → response includes terminal_cwd.""" + import os + home = os.path.expanduser("~") + + _write_capture_json(ac_root, "2026-04-26T14-35-00p08-00", { + "timestamp": "2026-04-26T14:35:00+08:00", + "schema_version": 2, + "window_meta": { + "app_name": "iTerm2", + "title": "vim — ~/projects/foo — zsh", + "bundle_id": "com.googlecode.iterm2", + }, + "ax_tree": {}, + "focused_element": { + "role": "AXTextArea", "title": "terminal", + "value": "$ ls", "is_editable": False, + "value_length": 4, + }, + "visible_text": "## iTerm2\n### vim — ~/projects/foo — zsh\n- [AXTextArea] terminal — $ ls", + "url": None, + "editor_file": None, + "editor_project": None, + "editor_git_branch": None, + "terminal_cwd": f"{home}/projects/foo", + "screenshot_stripped": True, + }) + + result = captures_mod.read_recent_capture( + at="2026-04-26T14:35:00+08:00", app_name="iTerm2", + ) + assert result is not None, "should find the iTerm2 capture" + assert result["terminal_cwd"] == f"{home}/projects/foo" + assert result["editor_file"] is None + assert result["editor_project"] is None + + +def test_read_recent_capture_non_editor_has_null_fields(ac_root: Path) -> None: + """Agent calls read_recent_capture on a non-editor app → fields are None.""" + _write_capture_json(ac_root, "2026-04-26T14-40-00p08-00", { + "timestamp": "2026-04-26T14:40:00+08:00", + "schema_version": 2, + "window_meta": { + "app_name": "Safari", + "title": "Example", + "bundle_id": "com.apple.Safari", + }, + "ax_tree": {}, + "focused_element": { + "role": "AXStaticText", "title": "", + "value": "some content", "is_editable": False, + "value_length": 12, + }, + "visible_text": "## Safari\n### Example\n- some content", + "url": "https://example.com", + "editor_file": None, + "editor_project": None, + "editor_git_branch": None, + "terminal_cwd": None, + "screenshot_stripped": True, + }) + + result = captures_mod.read_recent_capture( + at="2026-04-26T14:40:00+08:00", app_name="Safari", + ) + assert result is not None, "should find the Safari capture" + assert result["url"] == "https://example.com" + assert result["editor_file"] is None + assert result["editor_project"] is None + assert result["editor_git_branch"] is None + assert result["terminal_cwd"] is None diff --git a/tests/test_s1_parser.py b/tests/test_s1_parser.py index 87726446..52d69bfe 100644 --- a/tests/test_s1_parser.py +++ b/tests/test_s1_parser.py @@ -204,3 +204,198 @@ def test_enrich_falls_back_to_first_app_when_no_frontmost() -> None: } s1_parser.enrich(capture) assert "hello" in capture["visible_text"] + + +# ── Editor / terminal S1 field extraction ──────────────────────────────────── + + +def test_extract_editor_info_standard() -> None: + """main.py — project-name - Visual Studio Code""" + file, project, branch = s1_parser._extract_editor_info( + "main.py — openchronicle - Visual Studio Code", + "com.microsoft.VSCode", + ) + assert file == "main.py" + assert project == "openchronicle" + assert branch is None + + +def test_extract_editor_info_with_git_branch() -> None: + """Cursor with [git:feat/auth] in title.""" + file, project, branch = s1_parser._extract_editor_info( + "app.ts — website [git:feat/auth] - Cursor", + "com.todesktop.230313mzl4w4u92", + ) + assert file == "app.ts" + assert project == "website" + assert branch == "git:feat/auth" + + +def test_extract_editor_info_no_project() -> None: + """VS Code with no project folder open.""" + file, project, branch = s1_parser._extract_editor_info( + "main.py - Visual Studio Code", + "com.microsoft.VSCode", + ) + assert file == "main.py" + assert project is None + assert branch is None + + +def test_extract_editor_info_wsl_branch() -> None: + """WSL remote — [WSL:Ubuntu] bracket annotation.""" + file, project, branch = s1_parser._extract_editor_info( + "config.rs — my-project [WSL:Ubuntu] - Visual Studio Code", + "com.microsoft.VSCode", + ) + assert file == "config.rs" + assert project == "my-project" + assert branch == "WSL:Ubuntu" + + +def test_extract_editor_info_bare_bracket_branch() -> None: + """Bare [main] branch annotation (no git: prefix).""" + file, project, branch = s1_parser._extract_editor_info( + "README.md — docs [main] - Visual Studio Code", + "com.microsoft.VSCode", + ) + assert file == "README.md" + assert project == "docs" + assert branch == "main" + + +def test_extract_terminal_cwd_iterm2() -> None: + """iTerm2 default title: command — cwd — shell.""" + cwd = s1_parser._extract_terminal_cwd( + "vim — ~/projects/foo — zsh" + ) + assert cwd is not None + assert cwd.endswith("/projects/foo") + + +def test_extract_terminal_cwd_ssh() -> None: + """SSH title: user@host:/path.""" + cwd = s1_parser._extract_terminal_cwd( + "user@host: /var/www/html" + ) + assert cwd == "/var/www/html" + + +def test_extract_terminal_cwd_default() -> None: + """Default terminal title without a path.""" + cwd = s1_parser._extract_terminal_cwd("Terminal") + assert cwd is None + + +def test_extract_terminal_cwd_absolute_path() -> None: + """Absolute path in terminal title.""" + cwd = s1_parser._extract_terminal_cwd( + "/tmp/build — bash — 80x24" + ) + assert cwd == "/tmp/build" + + +# ── enrich() integration with editor fields ────────────────────────────────── + + +def test_enrich_vscode_sets_editor_fields() -> None: + """Full enrich() path for VS Code: window_meta title + AX tree.""" + capture = { + "window_meta": { + "app_name": "Code", + "title": "s1_parser.py — openchronicle [git:main] - Visual Studio Code", + "bundle_id": "com.microsoft.VSCode", + }, + "ax_tree": _ax_tree( + { + "name": "Code", + "bundle_id": "com.microsoft.VSCode", + "is_frontmost": True, + "windows": [ + { + "title": "s1_parser.py — openchronicle [git:main] - Visual Studio Code", + "focused": True, + "elements": [ + { + "role": "AXTextArea", + "title": "editor", + "value": "def enrich(capture):\n ...", + } + ], + } + ], + } + ), + } + s1_parser.enrich(capture) + assert capture["editor_file"] == "s1_parser.py" + assert capture["editor_project"] == "openchronicle" + assert capture["editor_git_branch"] == "git:main" + + +def test_enrich_terminal_sets_cwd() -> None: + """Full enrich() path for iTerm2.""" + import os + home = os.path.expanduser("~") + + capture = { + "window_meta": { + "app_name": "iTerm2", + "title": "vim — ~/projects/foo — zsh", + "bundle_id": "com.googlecode.iterm2", + }, + "ax_tree": _ax_tree( + { + "name": "iTerm2", + "bundle_id": "com.googlecode.iterm2", + "is_frontmost": True, + "windows": [ + { + "title": "vim — ~/projects/foo — zsh", + "focused": True, + "elements": [ + { + "role": "AXTextArea", + "title": "terminal", + "value": "$ ls\nfile1.txt", + } + ], + } + ], + } + ), + } + s1_parser.enrich(capture) + assert capture["terminal_cwd"] == f"{home}/projects/foo" + + +def test_enrich_non_editor_terminal_fields_are_none() -> None: + """Non-editor/non-terminal app gets None for all app-specific fields.""" + capture = { + "window_meta": { + "app_name": "Safari", + "title": "Example", + "bundle_id": "com.apple.Safari", + }, + "ax_tree": _ax_tree( + { + "name": "Safari", + "bundle_id": "com.apple.Safari", + "is_frontmost": True, + "windows": [ + { + "title": "Example", + "focused": True, + "elements": [ + {"role": "AXStaticText", "value": "some content"} + ], + } + ], + } + ), + } + s1_parser.enrich(capture) + assert capture["editor_file"] is None + assert capture["editor_project"] is None + assert capture["editor_git_branch"] is None + assert capture["terminal_cwd"] is None