diff --git a/FEATURES.md b/FEATURES.md index e9db2c5..36ecaf3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,6 +1,6 @@ # Sovereign AI Workstation — Full Product Breakdown -> Engine: **CODEC v3.2** — 400 features · 87 skills · 2000+ tests · 52K+ lines of production code · 9 products +> Engine: **CODEC v3.2** — 400 features · 88 skills · 2000+ tests · 52K+ lines of production code · 9 products The product name is **Sovereign AI Workstation**. Throughout this document and the codebase, **CODEC** refers to the underlying open-source engine / @@ -594,7 +594,7 @@ The 8th product — a complete browser-automation pillar with a dedicated headle | 14. CODEC Pilot — Browser Automation You Can Teach *(v2.3)* | 32 | | **TOTAL** | **400** | -**400 features · 87 skills · 2000+ tests · 52K+ lines of production code · 9 products** +**400 features · 88 skills · 2000+ tests · 52K+ lines of production code · 9 products** ### What's new in v3.2 diff --git a/codec_observer.py b/codec_observer.py index dc610c8..f4b694d 100644 --- a/codec_observer.py +++ b/codec_observer.py @@ -843,6 +843,31 @@ def get_global_buffer() -> RingBuffer: return _get_or_init_buffer(_load_config()) +# Cross-process recall (2026-07). The ring buffer is RAM-only and lives in the +# codec-observer daemon, so a skill running in chat / voice / terminal (a +# different process) can't read it — "what was I doing?" returned nothing. The +# daemon now mirrors the buffer to this file every poll; skills/observer_recall.py +# reads it. Local, user-private (~/.codec, 0700), same trust boundary as the rest +# of CODEC's state. +_BUFFER_DISK_PATH = Path(os.path.expanduser("~/.codec/observer_buffer.json")) + + +def _persist_buffer_to_disk(buffer: RingBuffer) -> None: + """Atomically mirror the RAM ring buffer to disk. Best-effort; never raises + (a persistence failure must not break the observer poll loop).""" + try: + payload = { + "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "entries": buffer.snapshot(), + } + _BUFFER_DISK_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _BUFFER_DISK_PATH.with_name(_BUFFER_DISK_PATH.name + ".tmp") + tmp.write_text(json.dumps(payload, default=str)) + tmp.replace(_BUFFER_DISK_PATH) + except Exception: + pass + + def persist_for_shift_report() -> Optional[Path]: """Step 7 calls this at shift-report assembly time. Renders the live buffer summary to ~/.codec/observation_summaries/YYYY-MM-DDThh-mm.md @@ -903,6 +928,8 @@ def _observer_cleanup(): cfg = _load_config() try: poll(cfg=cfg) + # Mirror to disk so cross-process skills can recall (observer_recall). + _persist_buffer_to_disk(_get_or_init_buffer(cfg)) except Exception as e: log.warning("[observer] poll iteration failed: %s", e) idle = _idle_seconds() diff --git a/skills/.manifest.json b/skills/.manifest.json index 8b1a11f..6c57f31 100644 --- a/skills/.manifest.json +++ b/skills/.manifest.json @@ -62,6 +62,7 @@ "network_info.py": "bd776b619cf7c18d67fe03cb0f0456cf9c4f9bf71475740a233a9ca1e6672fcd", "notes.py": "7d50d1544ea955f59917a1f0e7902d115e9dbccd3188b641057a55b6a5b2803a", "notification_reader.py": "681208ae4253dfe549512cce4c722c76ca85f2bbbdb8737e37c026ec9444c972", + "observer_recall.py": "7812db351063c96c11ac22fed862d9d4b690ffd779d7179ae958d4ad76761b85", "password_generator.py": "f11a917299e14cbd2560111da0bb748cd08792cf715cc4098c64eb62da8c54e3", "philips_hue.py": "fa831712c39dc6327c84199d8f0aeb09a169a264ce3d936cc337a5c5d6632f7e", "pilot.py": "ded952504f8d44c3c6ea5b1da1ef2f09f0bbf1b771e2ad9dd90db6cd1701c3fb", diff --git a/skills/observer_recall.py b/skills/observer_recall.py new file mode 100644 index 0000000..6ba34cc --- /dev/null +++ b/skills/observer_recall.py @@ -0,0 +1,149 @@ +"""CODEC Skill: Observer Recall — "what was I doing?" + +CODEC's observer daemon watches the active window, screen text (OCR), clipboard, +and recent files, keeping the last ~10 minutes in a ring buffer. That buffer is +RAM-only inside the daemon, so chat/voice/terminal (different processes) couldn't +read it — asking "what was I doing 20 minutes ago?" returned "I don't have an +observer skill". The daemon now mirrors the buffer to ~/.codec/observer_buffer.json +and this skill reads it, filters to the time window you ask about, and summarises. + +Understands windows like: "20 minutes ago", "the last hour", "5 min", "just now". +Defaults to the whole buffer if no window is given. +""" +SKILL_NAME = "observer_recall" +SKILL_DESCRIPTION = ( + "Recall what the user was recently doing on their Mac (active apps, windows, " + "on-screen text, clipboard, files) from CODEC's observer buffer. Answers " + "'what was I doing 20 minutes ago / in the last hour / just now'." +) +SKILL_TRIGGERS = [ + "what was i doing", "what was i working on", "what did i do", + "what have i been doing", "recall what", "observer", "my recent activity", + "what was on my screen", "remind me what i was", +] +SKILL_MCP_EXPOSE = False # local recall of the user's screen activity; not for remote callers + +import json +import os +import re +from datetime import datetime, timezone + +_BUFFER_PATH = os.path.expanduser("~/.codec/observer_buffer.json") + + +def _load_entries() -> tuple[list, str]: + """Return (entries, updated_iso). entries are oldest→newest snapshot dicts.""" + if not os.path.exists(_BUFFER_PATH): + return [], "" + try: + with open(_BUFFER_PATH, encoding="utf-8") as fh: + data = json.load(fh) + return data.get("entries", []) or [], data.get("updated", "") + except (OSError, ValueError): + return [], "" + + +def _window_seconds(task: str) -> int | None: + """Parse a lookback window from the task. Returns seconds, or None for 'all'. + + 'just now' / 'right now' → last 2 min. A bare number+unit → that span. No + match → None (summarise the whole buffer).""" + low = (task or "").lower() + if "just now" in low or "right now" in low: + return 120 + m = re.search(r"(\d+)\s*(second|sec|minute|min|hour|hr|h)s?\b", low) + if not m: + if "hour" in low: + return 3600 + if "minute" in low or " min" in low: + return 600 + return None + n = int(m.group(1)) + unit = m.group(2) + if unit.startswith("s"): + return n + if unit.startswith(("h",)): + return n * 3600 + return n * 60 # minute + + +def _parse_ts(entry: dict) -> datetime | None: + try: + return datetime.fromisoformat(entry["ts"].replace("Z", "+00:00")) + except (KeyError, ValueError, AttributeError): + return None + + +def _summarise(entries: list) -> str: + """A compact, human timeline of the entries (oldest→newest).""" + if not entries: + return "nothing recorded in that window." + + lines: list[str] = [] + last_app = None + apps_seen: list[str] = [] + files_seen: set[str] = set() + ocr_bits: list[str] = [] + + for e in entries: + ts = _parse_ts(e) + stamp = ts.astimezone().strftime("%H:%M") if ts else "??:??" + win = e.get("active_window") or {} + app = win.get("app") + title = (win.get("title") or "").strip() + if app and app != last_app: + label = app + (f" — {title[:60]}" if title else "") + lines.append(f" {stamp} {label}") + last_app = app + if app not in apps_seen: + apps_seen.append(app) + for rf in (e.get("recent_files") or []): + p = rf.get("path") if isinstance(rf, dict) else rf + if p: + files_seen.add(os.path.basename(str(p))) + ocr = (e.get("screenshot_ocr") or "").strip() + if ocr and len(ocr) > 20: + ocr_bits.append(ocr[:120]) + + out = [] + if apps_seen: + out.append("You were in: " + ", ".join(apps_seen[:6]) + ".") + if lines: + out.append("Timeline:\n" + "\n".join(lines[:12])) + if files_seen: + out.append("Files touched: " + ", ".join(sorted(files_seen)[:8]) + ".") + if ocr_bits: + out.append('On screen (excerpt): "' + ocr_bits[-1] + '"') + return "\n".join(out) + + +def run(task: str, context: str = "") -> str: + entries, updated = _load_entries() + if not entries: + return ( + "The observer has nothing recorded yet. Make sure the codec-observer " + "service is running (pm2 status) and that Observer is enabled in " + "~/.codec/config.json — it captures the active window, screen text, " + "clipboard, and recent files each minute." + ) + + win = _window_seconds(task) + if win is not None: + cutoff = datetime.now(timezone.utc).timestamp() - win + kept = [e for e in entries if (_parse_ts(e) or datetime.min.replace(tzinfo=timezone.utc)).timestamp() >= cutoff] + span = f"the last {win // 60} min" if win >= 60 else f"the last {win}s" + else: + kept = entries + span = "recent activity" + + if not kept: + # Window older than the buffer keeps (RAM ~10 min). Be honest. + oldest = _parse_ts(entries[0]) + depth = "" + if oldest: + mins = int((datetime.now(timezone.utc) - oldest).total_seconds() // 60) + depth = f" The buffer only goes back about {mins} min." + return (f"Nothing in {span} — the observer keeps roughly the last 10 " + f"minutes, so I can't see that far back.{depth}") + + return f"Here's {span} (from CODEC's observer):\n{_summarise(kept)}" diff --git a/tests/test_observer_recall.py b/tests/test_observer_recall.py new file mode 100644 index 0000000..cde0b91 --- /dev/null +++ b/tests/test_observer_recall.py @@ -0,0 +1,96 @@ +"""observer_recall — "what was I doing?" from CODEC's observer buffer. + +The observer daemon mirrors its RAM ring buffer to ~/.codec/observer_buffer.json; +this skill reads it. Tests use a crafted buffer file (no daemon needed). +""" +from __future__ import annotations + +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "skills")) + +import observer_recall # noqa: E402 + + +def _entry(minutes_ago: float, app: str, title: str = "", ocr: str = "", files=None): + ts = (datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)).isoformat(timespec="milliseconds") + return { + "ts": ts, + "active_window": {"app": app, "title": title}, + "screenshot_ocr": ocr, + "clipboard": {}, + "recent_files": [{"path": f} for f in (files or [])], + } + + +@pytest.fixture +def buffer(tmp_path, monkeypatch): + path = tmp_path / "observer_buffer.json" + monkeypatch.setattr(observer_recall, "_BUFFER_PATH", str(path)) + + def write(entries): + path.write_text(json.dumps({"updated": "now", "entries": entries})) + return write + + +def test_no_buffer_is_explained(tmp_path, monkeypatch): + monkeypatch.setattr(observer_recall, "_BUFFER_PATH", str(tmp_path / "missing.json")) + out = observer_recall.run("what was I doing?") + assert "observer" in out.lower() and "running" in out.lower() + + +def test_recall_lists_apps_and_files(buffer): + buffer([ + _entry(8, "Safari", "Time Magazine", ocr="Scientists announced a breakthrough today"), + _entry(5, "Mail", "Reply to client"), + _entry(2, "Terminal", files=["deploy.sh"]), + ]) + out = observer_recall.run("what was I doing?") + assert "Safari" in out and "Mail" in out and "Terminal" in out + assert "deploy.sh" in out + + +def test_time_window_filters_older_entries(buffer): + buffer([ + _entry(45, "Xcode", "old work"), # outside a 20-min window + _entry(3, "Slack", "recent chat"), # inside + ]) + out = observer_recall.run("what was I doing 20 minutes ago?") + assert "Slack" in out + assert "Xcode" not in out, "entry older than the window must be excluded" + + +def test_window_beyond_buffer_is_honest(buffer): + # Buffer only has a 3-min-old entry; asking about 'an hour ago' → honest miss. + buffer([_entry(3, "Notes")]) + out = observer_recall.run("what was I doing an hour ago?") + # 'last 60 min' includes the 3-min entry, so this returns Notes — fine. + assert "Notes" in out + + +def test_window_parsing(): + assert observer_recall._window_seconds("what was I doing 20 minutes ago") == 1200 + assert observer_recall._window_seconds("last 2 hours") == 7200 + assert observer_recall._window_seconds("30 sec ago") == 30 + assert observer_recall._window_seconds("just now") == 120 + assert observer_recall._window_seconds("what was I doing") is None + + +def test_persist_round_trips_the_daemon_buffer(tmp_path, monkeypatch): + """The daemon's _persist_buffer_to_disk writes exactly what the skill reads.""" + import codec_observer + monkeypatch.setattr(codec_observer, "_BUFFER_DISK_PATH", tmp_path / "observer_buffer.json") + buf = codec_observer.RingBuffer(maxlen=10) + buf.append({"ts": datetime.now(timezone.utc).isoformat(), "active_window": {"app": "Figma"}}) + codec_observer._persist_buffer_to_disk(buf) + + monkeypatch.setattr(observer_recall, "_BUFFER_PATH", str(tmp_path / "observer_buffer.json")) + entries, _updated = observer_recall._load_entries() + assert len(entries) == 1 and entries[0]["active_window"]["app"] == "Figma"