Skip to content
Open
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
176 changes: 175 additions & 1 deletion src/openchronicle/capture/s1_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import os
import re
from dataclasses import asdict, dataclass
from typing import Any
Expand All @@ -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"}
Expand Down Expand Up @@ -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).
"""
Expand All @@ -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 []
Expand Down Expand Up @@ -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 " - <AppDisplayName>" 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
5 changes: 5 additions & 0 deletions src/openchronicle/mcp/captures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
14 changes: 14 additions & 0 deletions src/openchronicle/timeline/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
122 changes: 122 additions & 0 deletions tests/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading