diff --git a/AGENTS.md b/AGENTS.md index 037ce9b..5a59ea1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ codec_dispatch.py Skill trigger matching for voice/wake-word path codec_memory.py SQLite + FTS5 + public API codec_memory_upgrade.py Facts table, CCF compression, tiered retrieval codec_compaction.py Context compaction — summarize old turns when window fills +codec_daybreak.py Daybreak: morning kickoff briefing + working-threads live memory (threads = temporal facts; docs/DAYBREAK-DESIGN.md) codec_audit.py Structured audit log (see §6) codec_audit_analyzer.py Audit summary skill (audit_report) codec_hooks.py Plugin lifecycle hooks (Phase 1 Step 2 — see §3) @@ -379,7 +380,7 @@ close() -> None ``` ### Facts table -Temporal KV with `valid_from`, `valid_until`, `superseded_by`. Supports `valid_at(timestamp)` queries — time-travel over user state. +Temporal KV with `valid_from`, `valid_until`, `superseded_by`. NOTE (2026-06-09 correction): a `valid_at(timestamp)` time-travel query does NOT exist in code — currently-active facts are `valid_until IS NULL` (`query_valid_facts`); full timelines via `get_fact_history`; close-without-replace via `expire_fact` (added for Daybreak). Daybreak working threads live here as `key="thread:{kind}:{slug}"`, `fact_type="thread"` (docs/DAYBREAK-DESIGN.md). ### CCF (Conversational Context Fragmentation) Rule-based compressor for memory writes that need shrinking. Entity abbreviation + filler stripping. Personal entity entries belong in `~/.codec/entity_map.json` (private), not in source. @@ -570,6 +571,16 @@ Three event names, all info-level. `agent_message_sent` and `agent_message_recei Single-emit, fresh or session cid. Think-mode tool calls need no new events — they route through the skill `Tool` wrappers → `run_with_hooks` → existing `tool_call`/`tool_result` envelope. +#### Daybreak events (morning kickoff + working threads — docs/DAYBREAK-DESIGN.md) + +Three event names, all info-level, single-emit with fresh cid. `DAYBREAK_EVENTS` frozenset exposed. Thread text never enters audit lines (keys/lengths only). + +| Event | Source | level | extra fields | +|---|---|---|---| +| `daybreak_completed` | `codec-daybreak` | info | `sections_included` (0-4), `skipped_sources` (list), `open_threads_count`, `word_count`; `duration_ms` top-level | +| `daybreak_thread_saved` | `codec-daybreak` | info | `kind` (`working_on` \| `waiting_on` \| `priority` \| `follow_up`), `key`, `superseded` (bool), `text_len` | +| `daybreak_thread_closed` | `codec-daybreak` | info | `key`, `rows_expired` | + ### Notifications (`~/.codec/notifications.json`) Four sources can produce notifications: scheduler (crew completion), heartbeat (threshold alert), autopilot (ambient trigger), and Phase 1 Step 3's AskUserQuestion (`type="question"`). All write through `routes/_shared.py:51-127` except AskUserQuestion which writes via `codec_ask_user._write_question_notification`. diff --git a/codec_audit.py b/codec_audit.py index d8a253e..95625ef 100644 --- a/codec_audit.py +++ b/codec_audit.py @@ -348,6 +348,18 @@ def _new_correlation_id() -> str: PROACTIVE_SUGGESTION_DISMISSED, }) +# ───────────────────────────────────────────────────────────────────────────── +# Daybreak — morning kickoff + working-threads live memory +# (docs/DAYBREAK-DESIGN.md). All single-emit, info level, fresh cid. +# ───────────────────────────────────────────────────────────────────────────── +DAYBREAK_COMPLETED = "daybreak_completed" +DAYBREAK_THREAD_SAVED = "daybreak_thread_saved" +DAYBREAK_THREAD_CLOSED = "daybreak_thread_closed" + +DAYBREAK_EVENTS = frozenset({ + DAYBREAK_COMPLETED, DAYBREAK_THREAD_SAVED, DAYBREAK_THREAD_CLOSED, +}) + SHIFT_REPORT_EXTRA_FIELDS = ( "trigger_kind", # "time" | "idle" | "manual" "sections_included", # int — how many of the 5 sections rendered diff --git a/codec_dashboard.py b/codec_dashboard.py index 2b71cee..315e25e 100644 --- a/codec_dashboard.py +++ b/codec_dashboard.py @@ -723,6 +723,7 @@ async def vibe_page(): [SKILL:file_ops:read file ~/notes.txt] [SKILL:pm2_control:pm2 list] [SKILL:google_calendar:what's on my calendar today] +"good morning" / "where did we leave off?" / "start my day" → [SKILL:daily_kickoff:morning kickoff] The skill's real output replaces the tag automatically — emit the tag and stop, never fabricate the result. ## Slash commands diff --git a/codec_daybreak.py b/codec_daybreak.py new file mode 100644 index 0000000..0348f76 --- /dev/null +++ b/codec_daybreak.py @@ -0,0 +1,464 @@ +"""CODEC Daybreak — morning kickoff briefing + working-threads live memory. + +docs/DAYBREAK-DESIGN.md. Two halves: + +1. Working threads: persistent "what the user is up to" memory, stored as + temporal facts (key = "thread:{kind}:{slug}") in the existing facts table. + They ride the voice/wake-word [ACTIVE FACTS] prompt injection automatically; + chat injects get_working_context() (routes/chat.py). + +2. assemble_briefing(): "good morning CODEC" — where we left off yesterday, + open threads, today's calendar/weather, follow-ups, suggested priorities. + Never raises; slow sources are reaped against a time budget. + +Kill switch: DAYBREAK_ENABLED env (default true; false on false|0|no|off). +""" +from __future__ import annotations + +import glob +import importlib +import json +import logging +import os +import re +import secrets +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone + +log = logging.getLogger(__name__) + +DAYBREAK_SOURCE = "codec-daybreak" +THREAD_KEY_PREFIX = "thread:" +THREAD_KINDS = ("working_on", "waiting_on", "priority", "follow_up") +WORKING_CONTEXT_CHAR_CAP = 600 # ~150 tokens +MAX_CONTEXT_THREADS = 7 +DEFAULT_TIME_BUDGET_S = 8.0 +_RENDER_MARGIN_S = 0.5 + +_NOTIFICATIONS_PATH = os.path.expanduser("~/.codec/notifications.json") +_AGENTS_DIR = os.path.expanduser("~/.codec/agents") +_AUDIT_LOG = os.path.expanduser("~/.codec/audit.log") + + +def _enabled() -> bool: + val = (os.environ.get("DAYBREAK_ENABLED") or "true").strip().lower() + return val not in ("false", "0", "no", "off") + + +def _cfg() -> dict: + try: + with open(os.path.expanduser("~/.codec/config.json")) as f: + return json.load(f).get("daybreak", {}) or {} + except Exception: + return {} + + +# ── Working threads (temporal facts) ───────────────────────────────────────── + +def _slug(text: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", text.lower()[:40]).strip("-") + return s or "thread" + + +def save_thread(kind: str, text: str, user_id: str = "default") -> int: + """Track an open thread. Same kind+text re-save supersedes the old row.""" + if kind not in THREAD_KINDS: + kind = "working_on" + text = (text or "").strip()[:300] + import codec_memory_upgrade as cmu + key = f"{THREAD_KEY_PREFIX}{kind}:{_slug(text)}" + superseded = bool(cmu.query_valid_facts(key=key, user_id=user_id)) + new_id = cmu.store_fact(key, text, fact_type="thread", + user_id=user_id, source="daybreak", supersede=True) + _emit("daybreak_thread_saved", + extra={"kind": kind, "key": key, "superseded": superseded, + "text_len": len(text)}) + return new_id + + +def get_open_threads(user_id: str = "default") -> list[dict]: + import codec_memory_upgrade as cmu + out = [] + try: + for f in cmu.query_valid_facts(user_id=user_id, limit=200): + key = f.get("key", "") + if not key.startswith(THREAD_KEY_PREFIX): + continue + parts = key.split(":", 2) + kind = parts[1] if len(parts) > 1 and parts[1] in THREAD_KINDS else "working_on" + out.append({"id": f.get("id"), "key": key, "kind": kind, + "text": f.get("value", ""), + "since": (f.get("valid_from") or "")[:10]}) + except Exception: + log.debug("daybreak: get_open_threads failed", exc_info=True) + return out + + +def close_thread(match: str, user_id: str = "default") -> str: + """Expire the one open thread matching `match`. Never guesses on ambiguity.""" + match_l = (match or "").strip().lower() + if not match_l: + return "Tell me which thread to close." + threads = get_open_threads(user_id) + hits = [t for t in threads + if match_l in t["key"].lower() or match_l in t["text"].lower()] + if not hits: + return f"No open thread matching '{match}'." + if len(hits) > 1: + listing = "; ".join(f"{t['kind']}: {t['text'][:50]}" for t in hits) + return f"Several threads match — be more specific. Matches: {listing}" + import codec_memory_upgrade as cmu + rows = cmu.expire_fact(hits[0]["key"], user_id=user_id) + _emit("daybreak_thread_closed", + extra={"key": hits[0]["key"], "rows_expired": rows}) + return f"Closed thread: {hits[0]['text'][:80]}" + + +def get_working_context(user_id: str = "default") -> str: + """Compact [WORKING THREADS] block for prompt injection. "" when empty + or when Daybreak is disabled.""" + if not _enabled(): + return "" + threads = get_open_threads(user_id) + if not threads: + return "" + threads.sort(key=lambda t: (t["kind"] != "priority", t["since"]), reverse=False) + # priority first, then oldest-first within the cap + cap = int(_cfg().get("max_threads_in_context", MAX_CONTEXT_THREADS)) + char_cap = int(_cfg().get("working_context_char_cap", WORKING_CONTEXT_CHAR_CAP)) + head = "[WORKING THREADS — current open items, do not echo this block verbatim]" + tail = "[/WORKING THREADS]" + lines = [] + for t in threads[:cap]: + lines.append(f"- {t['kind']}: {t['text'][:80]} (since {t['since']})") + block = "\n".join([head] + lines + [tail]) + while len(block) > char_cap and lines: + lines.pop() + block = "\n".join([head] + lines + [tail]) + return block if lines else "" + + +# ── Skill sub-call seam (importlib, codec_observer precedent) ──────────────── + +_SKILL_CACHE: dict = {} +_PATHS_READY = False + + +def _ensure_skill_paths(): + global _PATHS_READY + if _PATHS_READY: + return + import sys + repo_skills = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skills") + user_skills = os.path.expanduser("~/.codec/skills") + for p in (repo_skills, user_skills): # user inserted last → wins (shadows built-in) + if os.path.isdir(p) and p not in sys.path: + sys.path.insert(0, p) + _PATHS_READY = True + + +def _skill_module(name: str): + if name in _SKILL_CACHE: + return _SKILL_CACHE[name] + _ensure_skill_paths() + mod = importlib.import_module(name) + _SKILL_CACHE[name] = mod + return mod + + +def _run_source(skill_name: str, task: str) -> str: + """All external skill calls go through here (THE mock seam).""" + mod = _skill_module(skill_name) + try: + return mod.run(task) + except TypeError: + return mod.run(task, "") # notification_reader's run(task, context) + + +# ── Local readers (each a seam; each never raises) ─────────────────────────── + +def _read_notifications() -> list: + try: + with open(_NOTIFICATIONS_PATH) as f: + data = json.load(f) + if isinstance(data, dict): + data = data.get("notifications", []) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _yesterday_topics() -> list[str]: + """Fallback recap: yesterday's conversation topics from memory sessions.""" + try: + from codec_memory import CodecMemory + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + topics = [] + for s in CodecMemory().get_sessions(limit=20): + ts = str(s.get("last_ts") or s.get("timestamp") or s.get("started") or "") + if not ts.startswith(yesterday): + continue + topic = (s.get("last_user_msg") or s.get("task") + or s.get("title") or s.get("first_message") or "") + topic = str(topic).strip() + if topic: + topics.append(topic[:70]) + return topics[:4] + except Exception: + return [] + + +def _recent_audit_records(hours: int = 24) -> list[dict]: + """Parse audit.log (+ rotations) records newer than the cutoff. Tolerant.""" + cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() + records = [] + try: + paths = sorted(glob.glob(_AUDIT_LOG + "*")) + for path in paths: + try: + with open(path) as f: + for line in f: + try: + rec = json.loads(line) + except Exception: + continue + if str(rec.get("ts", ""))[:19] >= cutoff[:19]: + records.append(rec) + except OSError: + continue + except Exception: + log.debug("daybreak: audit scan failed", exc_info=True) + return records + + +def _open_crews(records: list[dict]) -> list[str]: + started, finished = {}, set() + for r in records: + cid = (r.get("extra") or {}).get("correlation_id", "") + if not cid: + continue + ev = r.get("event", "") + if ev == "crew_start": + started[cid] = (r.get("extra") or {}).get("agents") or r.get("message", "")[:40] + elif ev in ("crew_complete", "crew_error"): + finished.add(cid) + return [f"crew {cid[:8]} ({info})" for cid, info in started.items() + if cid not in finished] + + +# Statuses actionable by the user THIS morning. plan_failed is terminal noise +# (weeks-old failed experiments would read as priorities) — excluded. +_BLOCKING_STATUSES = {"blocked_on_permission", "blocked_on_destructive", + "paused", "awaiting_approval", "revised"} + + +def _blocking_agents() -> list[dict]: + out = [] + try: + for mf in glob.glob(os.path.join(_AGENTS_DIR, "*", "manifest.json")): + try: + with open(mf) as f: + m = json.load(f) + if m.get("status") in _BLOCKING_STATUSES: + out.append({"title": m.get("title", os.path.basename(os.path.dirname(mf))), + "status": m.get("status")}) + except Exception: + continue + except Exception: + pass + return out + + +def _pending_questions() -> list[dict]: + try: + from codec_ask_user import _load_pending_questions + env = _load_pending_questions() + qs = env.get("pending_questions", []) if isinstance(env, dict) else [] + return [q for q in qs if q.get("status") == "pending"] + except Exception: + return [] + + +# ── Briefing assembly ──────────────────────────────────────────────────────── + +# Output-acceptance prefixes per source (anything else = audited failure string +# from that skill → skip the line rather than speak an error verbatim). +_ACCEPT = { + "google_calendar": ("Today's schedule", "No events today"), + "weather": ("Weather in",), + "google_gmail": ("Found",), + "reminders": ("Open reminders",), + "notification_reader": ("You have",), +} + + +def _emit(event: str, extra: dict | None = None, duration_ms: float | None = None): + try: + import codec_audit + x = dict(extra or {}) + x.setdefault("correlation_id", secrets.token_hex(6)) + codec_audit.log_event(event, DAYBREAK_SOURCE, "", extra=x, + duration_ms=duration_ms) + except Exception: + log.debug("daybreak: audit emit failed", exc_info=True) + + +def _shift_report_recap(notifications: list) -> list[str]: + cutoff = datetime.now() - timedelta(hours=36) + for n in notifications: + if n.get("type") != "shift_report": + continue + try: + created = datetime.strptime(str(n.get("created", ""))[:19], "%Y-%m-%dT%H:%M:%S") + except Exception: + continue + if created < cutoff: + continue + lines = [] + for ln in str(n.get("body", "")).splitlines(): + ln = ln.strip().lstrip("#*- ") + # drop title/markdown cruft — keep substance lines only + if not ln or ln.startswith("_") or "Shift Report" in ln or ln.endswith(":"): + continue + lines.append(ln) + if len(lines) >= 4: + break + return lines + return [] + + +def assemble_briefing(trigger_text: str = "") -> str: + """The Daybreak briefing. Spoken-friendly, never raises, budget-reaped.""" + if not _enabled(): + return "Daybreak is disabled." + t0 = time.monotonic() + cfg = _cfg() + budget = float(cfg.get("time_budget_seconds", DEFAULT_TIME_BUDGET_S)) + deadline = t0 + max(0.1, budget - _RENDER_MARGIN_S) + skipped: list[str] = [] + + # Kick off network sources first; do all local work while they run. + network = [] + if cfg.get("include_calendar", True): + network.append(("google_calendar", "what do i have today")) + if cfg.get("include_email", True): + network.append(("google_gmail", "unread emails")) + if cfg.get("include_weather", True): + network.append(("weather", "weather")) # bare → config default city ("today" parses as a city) + if cfg.get("include_reminders", True): + network.append(("reminders", "list reminders")) + network.append(("notification_reader", "count")) + + for name, _ in network: # pre-warm imports on the main thread (no lock races) + try: + _skill_module(name) + except Exception: + pass + + executor = ThreadPoolExecutor(max_workers=4) + futures = {name: executor.submit(_run_source, name, task) for name, task in network} + + # ── local work (fast, main thread) ── + threads = get_open_threads() + notifications = _read_notifications() + recap = _shift_report_recap(notifications) + if not recap: + recap = _yesterday_topics() + crews = _open_crews(_recent_audit_records(int(cfg.get("lookback_hours", 24)))) + agents = _blocking_agents() + questions = _pending_questions() + + # ── reap network sources against the budget ── + results: dict[str, str] = {} + for name, fut in futures.items(): + remaining = max(0.05, deadline - time.monotonic()) + try: + r = fut.result(timeout=remaining) + except Exception: + r = None + ok = isinstance(r, str) and r.startswith(_ACCEPT.get(name, ("",))) + if ok: + results[name] = r + else: + skipped.append(name) + executor.shutdown(wait=False) + + # ── render ── + hour = datetime.now().hour + greeting = "Good morning." if 4 <= hour < 12 else "Here's your kickoff." + parts = [greeting] + sections = 0 + + # 1 — where we left off + left = [] + if recap: + left.append("Yesterday: " + "; ".join(recap[:3]) + ".") + if threads: + left.append("Open threads:") + for t in threads[:MAX_CONTEXT_THREADS]: + left.append(f"- {t['kind'].replace('_', ' ')}: {t['text'][:90]} (since {t['since']})") + if crews: + left.append("Still running from before: " + "; ".join(crews[:3]) + ".") + if agents: + left.append("Waiting on you: " + "; ".join( + f"\"{a['title']}\" is {a['status'].replace('_', ' ')}" for a in agents[:3]) + ".") + if left: + parts.append("Where we left off:\n" + "\n".join(left)) + sections += 1 + elif not threads: + parts.append("Where we left off: no record of yesterday — clean start.") + + # 2 — today + today = [] + if "google_calendar" in results: + today.append(results["google_calendar"].strip()) + if "weather" in results: + today.append(results["weather"].strip().splitlines()[0]) + if today: + parts.append("Today:\n" + "\n".join(today)) + sections += 1 + + # 3 — follow-ups + follow = [] + if questions: + for q in questions[:3]: + follow.append(f"- {q.get('agent') or 'CODEC'} asked: {str(q.get('question', ''))[:80]}") + if "reminders" in results: + rem = results["reminders"].splitlines() + follow.extend(rem[:6]) + if "google_gmail" in results: + mail = [ln for ln in results["google_gmail"].splitlines() if not ln.startswith(" ")] + follow.extend(mail[:6]) + if "notification_reader" in results: + follow.append(results["notification_reader"].strip()) + if follow: + parts.append("Follow-ups:\n" + "\n".join(follow)) + sections += 1 + + # 4 — suggested priorities (derived, no I/O) + prio = [] + for t in threads: + if t["kind"] == "priority": + prio.append(t["text"][:80]) + for a in agents: + prio.append(f"unblock \"{a['title']}\"") + if questions: + prio.append("answer the pending question(s)") + for t in sorted((t for t in threads if t["kind"] == "working_on"), + key=lambda t: t["since"]): + prio.append(t["text"][:80]) + prio = prio[:4] + if prio: + parts.append("Priorities:\n" + "\n".join(f"- {p}" for p in prio)) + sections += 1 + else: + parts.append("Priorities: clean slate — pick your battle.") + + out = "\n\n".join(parts) + _emit("daybreak_completed", + extra={"sections_included": sections, "skipped_sources": skipped, + "open_threads_count": len(threads), + "word_count": len(out.split())}, + duration_ms=round((time.monotonic() - t0) * 1000, 1)) + return out diff --git a/codec_memory_upgrade.py b/codec_memory_upgrade.py index a4c64ad..72ca9b7 100644 --- a/codec_memory_upgrade.py +++ b/codec_memory_upgrade.py @@ -155,6 +155,24 @@ def query_valid_facts(key: Optional[str] = None, user_id: str = "default", c.close() +def expire_fact(key: str, user_id: str = "default") -> int: + """Close an active fact without replacing it (Daybreak thread close). + Sets valid_until=now; superseded_by stays NULL (closed, not superseded). + Returns the number of rows expired.""" + now = datetime.now().isoformat() + c = _conn() + try: + cur = c.execute( + "UPDATE facts SET valid_until=? " + "WHERE key=? AND user_id=? AND valid_until IS NULL", + (now, key, user_id), + ) + c.commit() + return cur.rowcount + finally: + c.close() + + def get_fact_history(key: str, user_id: str = "default") -> list[dict]: """Full timeline for a key — all versions, newest first.""" c = _conn() diff --git a/docs/DAYBREAK-DESIGN.md b/docs/DAYBREAK-DESIGN.md new file mode 100644 index 0000000..b8686e8 --- /dev/null +++ b/docs/DAYBREAK-DESIGN.md @@ -0,0 +1,121 @@ +# DAYBREAK — Morning Kickoff + Working-Threads Live Memory + +**Date:** 2026-06-09 · **Status:** APPROVED (operator option A, "all over — not just voice") + IMPLEMENTED. +Synthesized from a 5-reader code audit (shift-report internals, facts spine, action-skill +interfaces, chat gating, open-threads state). Tests: `tests/test_daybreak.py`. + +## 1. What & why + +"Good morning CODEC — where did we leave off, what's today, what needs follow-up?" answered +in <8s from **every** surface (voice, wake-word, chat, MCP), grounded in a persistent +**working-threads** memory of what the user is doing — the continuity spine of a true +assistant. + +## 2. Architecture + +- **`codec_daybreak.py`** (engine): `assemble_briefing(trigger_text)` + threads API + `save_thread(kind, text)` / `close_thread(match)` / `get_open_threads()` / + `get_working_context()` (≤600-char prompt block, priority-first, max 7). +- **Threads = temporal facts** in the existing `facts` table (`~/.codec/memory.db`): + `key = "thread:{kind}:{slug}"`, `kind ∈ working_on|waiting_on|priority|follow_up`, + `fact_type="thread"`, `source="daybreak"`. Same-key re-save auto-supersedes + (`store_fact(..., supersede=True)`); closing uses NEW additive + `codec_memory_upgrade.expire_fact(key)` (sets `valid_until`, keeps `superseded_by` NULL). + **Audit override honored:** documented `valid_at()` time-travel does NOT exist in code — + design only relies on `valid_until IS NULL` (AGENTS.md §5 corrected in this change). +- **Injection per surface:** + - *Voice + wake-word:* ZERO edits — both `_build_system_prompt`s already inject + `query_valid_facts(limit=20)` as `[ACTIVE FACTS]`; thread facts ride along automatically. + - *Chat:* facts were never injected; ONE additive block in + `routes/chat.py:_build_chat_system_prompt` right after the base prompt format — + `get_working_context()` (≤150 tokens), try/except, vanishes when disabled. + - *MCP:* no injection (Step-5 contract: mcp transport never injects); claude.ai calls the + MCP-exposed `daily_kickoff` / `thread_note` tools explicitly. +- **Skills (thin shims, both `SKILL_MCP_EXPOSE=True`):** + - `daily_kickoff` — triggers: "good morning", "where did we leave off", "where did we left + off", "where we left off", "start my day", "daybreak", "daily kickoff", "kick off my day". + Deliberately NO "briefing" (collides with the `daily_briefing` crew voice triggers) and + NO "what did i do today" (shift-report spurious-fire precedent). + - `thread_note` — every trigger contains "thread" (anti-spurious-fire): "note a thread", + "track a thread", "new thread", "close thread", "thread done", "open threads", + "my threads", "list threads". Kind inferred from text (waiting on→waiting_on, + priority→priority, follow up→follow_up, else working_on). +- **Skill sub-calls** from the engine use the codec_observer importlib precedent (one-time + sys.path of user skills dir + repo skills dir, module cache, pre-warm imports on the main + thread before the worker fan-out). Trade-off accepted: sub-calls bypass per-skill hooks; + the wrapping `daily_kickoff` dispatch is hooked + audited, and `daybreak_completed` + records which sections ran. + +## 3. Briefing assembly (4 sections, never raises, budget-reaped) + +1. **Where we left off** (local, main thread): newest `type="shift_report"` notification + ≤36h old (body head) → fallback `CodecMemory.get_sessions` yesterday topics; open + threads; `crew_start` without `crew_complete`/`crew_error` in last 24h (own audit-log + scanner, string-ts compare, rotation-aware); blocked/awaiting agents from + `~/.codec/agents/*/manifest.json` (statuses blocked_on_permission, blocked_on_destructive, + paused, awaiting_approval, revised; EXCLUDES blocked_on_qwen/running AND plan_failed — + live-fire showed weeks-old failed experiments reading as morning priorities). +2. **Today** (parallel threads): calendar via `_run_source("google_calendar", "what do i + have today")` (a `_READ_OVERRIDES` hard-forced read phrase); weather via + `_run_source("weather", "weather today")`. +3. **Follow-ups**: pending questions (read-only `codec_ask_user._load_pending_questions`, + status=="pending"); reminders (parallel; handles None return); unread email (parallel, + `"unread emails"` — contains neither "send" nor "from", avoiding both gmail traps); + notification count. +4. **Suggested priorities** (derived, no I/O): priority threads → blocked agents → pending + questions → oldest working_on; cap 4; else "Clean slate — pick your battle." + +**Budget:** ThreadPoolExecutor(4) for the network calls; main thread does all local reads +meanwhile; `future.result(timeout=remaining)` against `daybreak.time_budget_seconds` +(default 8, 0.5s render margin); timed-out source → "(X didn't answer in time)" line. + +## 4. Chat gating (audited exactly) + +- "good morning codec" = 3 words → `_is_conversational` False → pre-LLM hijack fires. +- "where did we left off yesterday" → no pattern hit → fires. +- **"?" trap is real**: any "?" → conversational → hijack skipped. Fixes: (a) both skills + added to `CHAT_SKILL_ALLOWLIST` (mandatory for either gate), (b) one example line in + `codec_dashboard._DASHBOARD_ADDON` teaching the post-LLM `[SKILL:daily_kickoff:...]` tag. + `_is_conversational` itself is NOT modified (shared blast radius). + +## 5. Config / audit / docs + +- Config (additive, no schema bump): `daybreak.{time_budget_seconds, lookback_hours, + max_threads_in_context, working_context_char_cap, include_calendar, include_weather, + include_email, include_reminders}`. +- Audit: 3 new single-emit events, `DAYBREAK_EVENTS` frozenset in `codec_audit.py`: + `daybreak_completed` (info; duration_ms top-level; extra sections_included, + skipped_sources, open_threads_count, word_count), `daybreak_thread_saved` (kind, key, + superseded, text_len), `daybreak_thread_closed` (key, rows_expired). Thread text never + enters audit lines. +- AGENTS.md: §2 repo map, §6 events, §5 `valid_at` correction. +- **No new files in `~/.codec/`** — threads are ordinary facts rows; no state file, no + notifications posted. + +## 6. Files touched + +NEW: `codec_daybreak.py`, `skills/daily_kickoff.py`, `skills/thread_note.py`, +`tests/test_daybreak.py`, this doc. +EDIT: `routes/chat.py` (allowlist + inject), `codec_dashboard.py` (addon example), +`codec_memory_upgrade.py` (`expire_fact`), `codec_audit.py` (constants), +`skills/.manifest.json` (regen), `AGENTS.md`. +**Zero edits** to any file in open PRs #189–#192 except shared regen/doc files +(`skills/.manifest.json`, `AGENTS.md`) — Daybreak PR lands AFTER those merge, rebased, +manifest regenerated fresh. + +## 7. Kill switch + rollback + +`DAYBREAK_ENABLED` env (default true; false|0|no|off): briefing returns "Daybreak is +disabled."; `get_working_context()` returns "" (chat injection vanishes). Thread +save/close stay functional (plain facts ops). Rollback: delete 3 files + revert 4 small +edits + manifest regen; optional data sweep +`UPDATE facts SET valid_until=datetime('now') WHERE key LIKE 'thread:%' AND valid_until IS NULL`. + +## 8. Known limitations (accepted) + +Calendar window is UTC-day (late-evening bleed at UTC+2); reminders can't distinguish +missing Automation permission from zero reminders; "?"-suffixed phrasings rely on the LLM +tag path; crew-boundary false positives at the 24h edge; voice 20-slot facts window shared +with other facts (7-thread cap + close-hygiene mitigate; dedicated voice block deferred +until #189–#192 merge). Separately logged: `fact_extract`'s `mem.store_fact` is a silent +no-op bug (pre-existing, out of scope → docs/known-issues.md). diff --git a/docs/known-issues.md b/docs/known-issues.md index ccd836a..39b498d 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -163,3 +163,10 @@ Notification posted to `~/.codec/notifications.json` with `type="shift_report"`, --- *Last updated: 2026-05-02 (Step 7 sign-off; Phase 2 complete).* + +## 2026-06-09 — fact_extract silently no-ops on fact storage +`skills/fact_extract.py:93-95` calls `mem.store_fact(...)` inside a swallowed +try/except AttributeError — but `CodecMemory` has no `store_fact` method (it lives in +`codec_memory_upgrade`). Extracted facts are therefore never written to the facts table. +Found during the Daybreak audit (2026-06-09); out of Daybreak scope. Fix: route to +`codec_memory_upgrade.store_fact` and add a round-trip test. diff --git a/routes/chat.py b/routes/chat.py index 19a68a7..c9652e7 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -363,6 +363,9 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) - # Phase 2 Step 6 — first declarative trigger (clipboard URL → web_fetch). # Read-only network fetch, gated by codec_ask_user.ask consent on auto-fire. "clipboard_url_fetch", + # Daybreak — morning kickoff (read-only aggregation) + working-thread + # capture (facts-table writes only). docs/DAYBREAK-DESIGN.md. + "daily_kickoff", "thread_note", } @@ -545,6 +548,16 @@ def _build_chat_system_prompt(config: dict, budget, has_attachment: bool, _overrides = _load_prompt_overrides() _chat_prompt = _overrides.get("chat", CHAT_SYSTEM_PROMPT) sys_prompt = _chat_prompt.format(date=_dt.now().strftime("%A, %B %d, %Y")) + # Daybreak working-threads context (docs/DAYBREAK-DESIGN.md): compact + # ≤150-token block of the user's open threads, so chat shares the same + # live memory voice already gets via [ACTIVE FACTS]. "" when disabled. + try: + from codec_daybreak import get_working_context + _wc = get_working_context() + if _wc: + sys_prompt += "\n\n" + _wc + except Exception: + pass if budget.warn_now(): sys_prompt += ( "\n\n⚠ 1 step remaining in this turn. Wrap up — do NOT " diff --git a/skills/.manifest.json b/skills/.manifest.json index bdf4f96..a969b53 100644 --- a/skills/.manifest.json +++ b/skills/.manifest.json @@ -34,6 +34,7 @@ "cookbook_serve.py": "8a08bb5deecd988431da421b1b4825cd411c7e9c7658bc547aa16ce74c0e1ddd", "cookbook_stop.py": "8b51e04ef08e08899df24d032af7a609bef7bf02ad72be5badf95eb0a2b1ce64", "create_skill.py": "28070280abfe2179d5c9b33eee74b4db5a5dd085f76a09f02d516302ba330036", + "daily_kickoff.py": "c649a0597265f284a876512c5689c2b210eb894ffddbd8da6b95f5e36a8cedab", "delegate.py": "7c595d5605cd9913a8afa331ae0013861d6996f378f8a59aca0249f1b2f3a474", "email_triage.py": "d7b9032b81179f58c5aff660c34f9b8edf212a8ce7651d307306e4757a8daaf2", "fact_extract.py": "a43ed03b8c51704415f4135de46a44e097f757305af3921dd389b8dfd0540906", @@ -78,6 +79,7 @@ "stuck.py": "dfddfe4dfa1a9d5c017f53e53033a7eb33114a517cd6fa937d9d44e65083f1c9", "system_info.py": "4cc32e0ad9cb81f34309734ef3388f7cde63407de9b23bb0cd589571a4c43ecb", "terminal.py": "1fe6c1cd1241ea6d328401f1c0fb4c362482e3ec2a61a4f7592b2d4822cd51c1", + "thread_note.py": "48cd4a992a6e985e57a786f4c232124f66e13e875e50351aa398c027b9cef576", "time_date.py": "b5a8f9341d8d4954833607eac41b5eb99e65e3bd30d0514a8b034245a7ca08dc", "timer.py": "e47cdabfc6b86814d74368ee00f98d11f86c59f5a76f5ac460940802e5ace560", "translate.py": "69f82c61e9ac87f2ca348f2cb82bf978560e6972ebbce6ef2d2823cf96eb3748", diff --git a/skills/daily_kickoff.py b/skills/daily_kickoff.py new file mode 100644 index 0000000..4f4f1cc --- /dev/null +++ b/skills/daily_kickoff.py @@ -0,0 +1,32 @@ +"""CODEC Daybreak — morning kickoff briefing (docs/DAYBREAK-DESIGN.md).""" +SKILL_NAME = "daily_kickoff" +SKILL_TRIGGERS = [ + "good morning", + "where did we leave off", + "where did we left off", + "where we left off", + "start my day", + "daybreak", + "daily kickoff", + "kick off my day", +] +SKILL_DESCRIPTION = ("Morning kickoff: where we left off yesterday, open working " + "threads, today's calendar and weather, follow-ups, and " + "suggested priorities.") +SKILL_MCP_EXPOSE = True + +import os +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +for _p in (_REPO, os.path.expanduser("~/codec-repo")): + if os.path.isdir(_p) and _p not in sys.path: + sys.path.insert(0, _p) + + +def run(task, app="", ctx=""): + try: + from codec_daybreak import assemble_briefing + return assemble_briefing(task) + except Exception as e: + return f"Daybreak hit a snag: {e}" diff --git a/skills/thread_note.py b/skills/thread_note.py new file mode 100644 index 0000000..7f37928 --- /dev/null +++ b/skills/thread_note.py @@ -0,0 +1,73 @@ +"""CODEC Daybreak — working-threads capture (docs/DAYBREAK-DESIGN.md). + +Every trigger contains the word "thread" on purpose — natural-language +triggers spuriously fire (the shift_report incident); the namespace keeps +this skill explicit-invocation only. +""" +SKILL_NAME = "thread_note" +SKILL_TRIGGERS = [ + "note a thread", "track a thread", "new thread", + "close thread", "close the thread", "thread done", + "open threads", "my threads", "list threads", +] +SKILL_DESCRIPTION = ("Track open working threads (working on / waiting on / " + "priority / follow up), list them, or close one when done. " + "Threads persist and inform CODEC's context everywhere.") +SKILL_MCP_EXPOSE = True + +import os +import re +import sys + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +for _p in (_REPO, os.path.expanduser("~/codec-repo")): + if os.path.isdir(_p) and _p not in sys.path: + sys.path.insert(0, _p) + +_CLOSE_RE = re.compile(r"\b(?:close(?:\s+the)?\s+thread|thread\s+done)\b[:,]?\s*(.*)", re.IGNORECASE) +_OPEN_RE = re.compile(r"\b(?:note\s+a\s+thread|track\s+a\s+thread|new\s+thread)\b[:,]?\s*(.*)", re.IGNORECASE) +_LIST_RE = re.compile(r"\b(?:open\s+threads|my\s+threads|list\s+threads)\b", re.IGNORECASE) + + +def _infer_kind(text): + low = text.lower() + if "waiting on" in low or "waiting for" in low: + return "waiting_on" + if "priority" in low: + return "priority" + if "follow up" in low or "follow-up" in low: + return "follow_up" + return "working_on" + + +def run(task, app="", ctx=""): + try: + import codec_daybreak as db + task = (task or "").strip() + + m = _CLOSE_RE.search(task) + if m: + target = m.group(1).strip() + if not target: + return "Tell me which thread to close." + return db.close_thread(target) + + if _LIST_RE.search(task): + threads = db.get_open_threads() + if not threads: + return "No open threads — clean slate." + lines = ["Open threads:"] + for t in threads: + lines.append(f"- {t['kind'].replace('_', ' ')}: {t['text'][:90]} (since {t['since']})") + return "\n".join(lines) + + m = _OPEN_RE.search(task) + text = m.group(1).strip() if m else "" + if not text: + return ("Usage: 'note a thread ', " + "'open threads', or 'close thread '.") + kind = _infer_kind(text) + db.save_thread(kind, text) + return f"Tracked ({kind.replace('_', ' ')}): {text[:90]}" + except Exception as e: + return f"Thread note error: {e}" diff --git a/tests/test_daybreak.py b/tests/test_daybreak.py new file mode 100644 index 0000000..bb4035c --- /dev/null +++ b/tests/test_daybreak.py @@ -0,0 +1,232 @@ +"""Tests for CODEC Daybreak — morning kickoff + working-threads live memory. + +Design: docs/DAYBREAK-DESIGN.md. Threads are temporal facts +(key = "thread:{kind}:{slug}") in the existing facts table; the briefing +assembles from mockable seams and never raises; trigger phrases are +collision-checked against the live registry; the chat conversational-guard +behavior ("?" trap) is pinned. +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "skills")) + +import codec_memory_upgrade # noqa: E402 +import codec_daybreak # noqa: E402 + + +# ── helpers ────────────────────────────────────────────────────────────────── +def _tmp_db(tmp_path, monkeypatch): + db = str(tmp_path / "daybreak_test.db") + monkeypatch.setattr(codec_memory_upgrade, "DB_PATH", db) + return db + + +# ── threads: save / supersede / close round-trips (temp DB) ───────────────── +def test_save_thread_roundtrip(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + codec_daybreak.save_thread("working_on", "ship Email-v2") + threads = codec_daybreak.get_open_threads() + assert len(threads) == 1 + t = threads[0] + assert t["key"] == "thread:working_on:ship-email-v2" + assert t["kind"] == "working_on" + assert "ship Email-v2" in t["text"] + + +def test_same_thread_resave_supersedes(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + codec_daybreak.save_thread("working_on", "ship Email-v2") + codec_daybreak.save_thread("working_on", "ship Email-v2") + key = "thread:working_on:ship-email-v2" + history = codec_memory_upgrade.get_fact_history(key) + assert len(history) == 2 + assert len(codec_memory_upgrade.query_valid_facts(key=key)) == 1 + assert len(codec_daybreak.get_open_threads()) == 1 + + +def test_close_thread_expires(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + codec_daybreak.save_thread("working_on", "ship Email-v2") + out = codec_daybreak.close_thread("email-v2") + assert "closed" in out.lower() + assert codec_daybreak.get_open_threads() == [] + hist = codec_memory_upgrade.get_fact_history("thread:working_on:ship-email-v2") + assert hist[0]["valid_until"] is not None + assert hist[0]["superseded_by"] is None # closed, not replaced + + +def test_close_thread_no_match_and_ambiguous(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + out = codec_daybreak.close_thread("nonexistent") + assert "no open thread" in out.lower() + codec_daybreak.save_thread("working_on", "hue overlay polish") + codec_daybreak.save_thread("follow_up", "hue PR review") + out = codec_daybreak.close_thread("hue") + assert len(codec_daybreak.get_open_threads()) == 2 # nothing expired + assert "which" in out.lower() or "specific" in out.lower() or "match" in out.lower() + + +# ── working context block (prompt injection) ──────────────────────────────── +def test_working_context_caps_and_priority_first(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + for i in range(50): + codec_daybreak.save_thread("working_on", f"task number {i} with some words") + codec_daybreak.save_thread("priority", "THE BIG ONE") + ctx = codec_daybreak.get_working_context() + assert len(ctx) <= codec_daybreak.WORKING_CONTEXT_CHAR_CAP + bullets = [ln for ln in ctx.splitlines() if ln.strip().startswith("-")] + assert len(bullets) <= 7 + assert "priority" in bullets[0] # priority kind sorts first + assert "THE BIG ONE" in bullets[0] + + +def test_working_context_empty_and_killswitch(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + assert codec_daybreak.get_working_context() == "" + codec_daybreak.save_thread("working_on", "anything") + monkeypatch.setenv("DAYBREAK_ENABLED", "false") + assert codec_daybreak.get_working_context() == "" + + +# ── briefing assembly (all seams mocked) ──────────────────────────────────── +def _mock_local_seams(monkeypatch, tmp_path): + _tmp_db(tmp_path, monkeypatch) + monkeypatch.setattr(codec_daybreak, "_read_notifications", lambda: []) + monkeypatch.setattr(codec_daybreak, "_yesterday_topics", lambda: ["hue lights fix"]) + monkeypatch.setattr(codec_daybreak, "_recent_audit_records", lambda hours=24: []) + monkeypatch.setattr(codec_daybreak, "_blocking_agents", lambda: []) + monkeypatch.setattr(codec_daybreak, "_pending_questions", lambda: []) + + +def test_briefing_all_sources_happy(tmp_path, monkeypatch): + _mock_local_seams(monkeypatch, tmp_path) + codec_daybreak.save_thread("priority", "record the demo video") + + def fake_source(skill, task): + return { + "google_calendar": "Today's schedule — 2 event(s):\n- 10:00 standup\n- 15:00 demo", + "weather": "Weather in Marbella: 27°C, sunny.", + "google_gmail": "Found 2 emails:\n* Bob: Invoice\n* Ana: Lunch", + "reminders": "Open reminders:\n- buy cables", + "notification_reader": "You have 3 unread notifications.", + }[skill] + + monkeypatch.setattr(codec_daybreak, "_run_source", fake_source) + emits = [] + import codec_audit + monkeypatch.setattr(codec_audit, "log_event", + lambda *a, **k: emits.append((a, k))) + + out = codec_daybreak.assemble_briefing("good morning") + assert "record the demo video" in out # threads + assert "standup" in out # calendar + assert "Marbella" in out # weather + assert "Bob" in out # email + assert "buy cables" in out # reminders + daybreak_emits = [e for e in emits if e[0][0] == "daybreak_completed"] + assert len(daybreak_emits) == 1 + + +def test_briefing_each_source_fails_gracefully(tmp_path, monkeypatch): + _mock_local_seams(monkeypatch, tmp_path) + + def fail_source(skill, task): + return { + "google_calendar": "Calendar error: token expired", + "weather": "Couldn't fetch weather right now.", + "google_gmail": "Gmail error: 401", + "reminders": None, # reminders.py can return None + "notification_reader": "Error reading notifications: down", + }[skill] + + monkeypatch.setattr(codec_daybreak, "_run_source", fail_source) + out = codec_daybreak.assemble_briefing() + assert isinstance(out, str) and len(out) > 0 # never raises, still renders + assert "Calendar error" not in out # raw error strings filtered + + +def test_briefing_total_failure_still_greets(tmp_path, monkeypatch): + _mock_local_seams(monkeypatch, tmp_path) + codec_daybreak.save_thread("working_on", "the only data point") + + def boom(skill, task): + raise RuntimeError("everything is down") + + monkeypatch.setattr(codec_daybreak, "_run_source", boom) + out = codec_daybreak.assemble_briefing() + assert "the only data point" in out # threads always render + + +def test_briefing_time_budget(tmp_path, monkeypatch): + _mock_local_seams(monkeypatch, tmp_path) + + def slow(skill, task): + time.sleep(5) + return "late" + + monkeypatch.setattr(codec_daybreak, "_run_source", slow) + monkeypatch.setattr(codec_daybreak, "DEFAULT_TIME_BUDGET_S", 0.3) + t0 = time.monotonic() + out = codec_daybreak.assemble_briefing() + assert time.monotonic() - t0 < 2.0 # reaped, not waited out + assert isinstance(out, str) + + +def test_briefing_killswitch(tmp_path, monkeypatch): + _tmp_db(tmp_path, monkeypatch) + monkeypatch.setenv("DAYBREAK_ENABLED", "0") + assert "disabled" in codec_daybreak.assemble_briefing().lower() + + +# ── triggers: real registry, collisions pinned ────────────────────────────── +def _dispatch(): + """Registry mirror of production startup: every entry path calls + load_skills() (scan) before check_skill.""" + import codec_dispatch + codec_dispatch.load_skills() + return codec_dispatch + + +def test_triggers_route_to_daily_kickoff(): + codec_dispatch = _dispatch() + for phrase in ("good morning codec", + "where did we left off yesterday", + "start my day"): + m = codec_dispatch.check_skill(phrase) + assert m is not None and m["name"] == "daily_kickoff", phrase + + +def test_triggers_do_not_steal_existing_skills(): + codec_dispatch = _dispatch() + m = codec_dispatch.check_skill("morning briefing please") + assert m is None or m["name"] != "daily_kickoff" # crew phrase stays clear + m = codec_dispatch.check_skill("what do i have today") + assert m is not None and m["name"] == "google_calendar" + # no daily_kickoff trigger may contain "briefing" + import daily_kickoff + assert all("briefing" not in t for t in daily_kickoff.SKILL_TRIGGERS) + + +def test_thread_note_triggers_are_namespaced(): + import thread_note + assert all("thread" in t for t in thread_note.SKILL_TRIGGERS) + codec_dispatch = _dispatch() + m = codec_dispatch.check_skill("note a thread im waiting on the IB broker") + assert m is not None and m["name"] == "thread_note" + + +# ── chat conversational-guard pins (the "?" trap) ─────────────────────────── +def test_conversational_guard_behavior(): + from codec_chat_pipeline import _is_conversational + assert _is_conversational("good morning codec") is False + assert _is_conversational("where did we left off yesterday") is False + assert _is_conversational("where did we leave off?") is True # documented trap + + +def test_chat_allowlist_membership(): + from routes.chat import CHAT_SKILL_ALLOWLIST + assert "daily_kickoff" in CHAT_SKILL_ALLOWLIST + assert "thread_note" in CHAT_SKILL_ALLOWLIST