diff --git a/CHANGELOG.md b/CHANGELOG.md index 507c5e4..7722c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- **Meetings (Quill integration) + after-hours digest processing.** Flowkey can connect to the local [Quill](https://quillapp.com) note-taking app over MCP to search your meetings and answer questions about them — entirely on the local model. Because asking a model about a full transcript costs real prefill time (~15–17 s of time-to-first-token for a ~7k-token transcript on the NPU), a background **scheduler** pre-computes a digest (summary / goals / action items) for each meeting during a configurable idle window (default 17:00–21:00, only when the machine has been idle), caching it in `data/meeting_digests.jsonl` so daytime reads are instant. New **Meetings** dashboard tab (search → read cached digest, "Process now", or "Ask about this meeting") and a Config card for all the settings (enable, Quill MCP URL, content source, schedule window, idle gating, max-per-run) with a "Run batch now" button. Off by default (opt-in). New modules `ffp_quill` (stdlib MCP-over-HTTP client) and `ffp_meetings` (digest store, batch worker, scheduler logic, idle detection); new config block `meetings`; new daemon actions `quill_status` / `quill_search_meetings` / `meeting_digest_get` / `meeting_digests_list` / `meeting_process` / `meeting_batch_run` / `meeting_batch_status` / `meeting_ask`. The Quill MCP URL is validated loopback-only; the meeting actions write a separate cache file under their own lock, so a long after-hours batch never blocks config saves or notifications. + ## 2.0.0 **The dashboard release.** Two things change what Flowkey *is* in 2.0: diff --git a/config/grammar_hotkey.config.example.json b/config/grammar_hotkey.config.example.json index aca4634..42c56d9 100644 --- a/config/grammar_hotkey.config.example.json +++ b/config/grammar_hotkey.config.example.json @@ -46,6 +46,20 @@ "action_result": { "enabled": true } } }, + "meetings": { + "enabled": false, + "mcp_url": "http://127.0.0.1:19532/mcp", + "source": "auto", + "max_context_tokens": 6000, + "batch": { + "enabled": true, + "start": "17:00", + "end": "21:00", + "only_when_idle": true, + "idle_minutes": 10, + "max_per_run": 10 + } + }, "hotkeys": { "grammar_fix": "^+g", "open_chat": "^!c", diff --git a/scripts/ffp_config.py b/scripts/ffp_config.py index 1534eed..ca1c15c 100644 --- a/scripts/ffp_config.py +++ b/scripts/ffp_config.py @@ -93,6 +93,20 @@ "action_result": {"enabled": True}, }, }, + "meetings": { + "enabled": False, + "mcp_url": "http://127.0.0.1:19532/mcp", + "source": "auto", + "max_context_tokens": 6000, + "batch": { + "enabled": True, + "start": "17:00", + "end": "21:00", + "only_when_idle": True, + "idle_minutes": 10, + "max_per_run": 10, + }, + }, "modes": { "grammar": { "label": "Grammar fix", @@ -370,6 +384,59 @@ def _filter_notifications_patch(value: dict) -> dict: out["categories"] = filtered_cats return out + +_PATCH_MEETINGS_SOURCES = frozenset({"auto", "minutes", "transcript"}) + + +def _validate_mcp_url(url: str) -> str: + """Quill MCP endpoint must be loopback http/https (SSRF guard, mirrors + validate_flm_base_url).""" + cleaned = str(url or "").strip() + if not cleaned: + return "http://127.0.0.1:19532/mcp" + parsed = urlparse(cleaned) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"meetings.mcp_url must use http/https, got {url!r}") + if (parsed.hostname or "").lower() not in _LOOPBACK_HOSTS: + raise ValueError(f"meetings.mcp_url must be loopback, got {url!r}") + return cleaned + + +def _filter_meetings_patch(value: dict) -> dict: + """Whitelist + type-coerce the meetings (Quill) settings block.""" + out: dict = {} + if "enabled" in value: + out["enabled"] = bool(value["enabled"]) + if "mcp_url" in value: + out["mcp_url"] = _validate_mcp_url(str(value["mcp_url"])) + if value.get("source") in _PATCH_MEETINGS_SOURCES: + out["source"] = value["source"] + if "max_context_tokens" in value: + try: + out["max_context_tokens"] = max(500, min(int(value["max_context_tokens"]), 32000)) + except (TypeError, ValueError): + pass + batch = value.get("batch") + if isinstance(batch, dict): + fb: dict = {} + if "enabled" in batch: + fb["enabled"] = bool(batch["enabled"]) + for time_key in ("start", "end"): + tv = batch.get(time_key) + if isinstance(tv, str) and _HHMM_RE.match(tv): + fb[time_key] = tv + if "only_when_idle" in batch: + fb["only_when_idle"] = bool(batch["only_when_idle"]) + for ik, lo, hi in (("idle_minutes", 0, 240), ("max_per_run", 1, 50)): + if ik in batch: + try: + fb[ik] = max(lo, min(int(batch[ik]), hi)) + except (TypeError, ValueError): + pass + if fb: + out["batch"] = fb + return out + # Built-in mode prompts stay locked against patching (system-prompt injection # guard); only tone.preset is patchable among them. User-defined modes accept # label + system_prompt under ids matching _MODE_ID_RE, and a JSON null value @@ -452,6 +519,10 @@ def filter_config_patch(patch: dict) -> dict: filtered = _filter_notifications_patch(value) if filtered: out[key] = filtered + elif key == "meetings" and isinstance(value, dict): + filtered = _filter_meetings_patch(value) + if filtered: + out[key] = filtered elif key == "hotkeys" and isinstance(value, dict): filtered = {k: v for k, v in value.items() if k in _PATCH_HOTKEYS_KEYS} if filtered: diff --git a/scripts/ffp_daemon.py b/scripts/ffp_daemon.py index abe1026..51699ab 100644 --- a/scripts/ffp_daemon.py +++ b/scripts/ffp_daemon.py @@ -630,6 +630,63 @@ def _act_chat_take_staged(_args: dict) -> dict: return ffp_chat.take_staged() +# ---- Quill meetings (MCP) + after-hours digest processing -------------------- +def _meetings_cfg() -> dict: + return grammar_fix.load_config().get("meetings") or {} + + +def _act_quill_status(_args: dict) -> dict: + import ffp_quill + return ffp_quill.status(str(_meetings_cfg().get("mcp_url") or ffp_quill.DEFAULT_MCP_URL)) + + +def _act_quill_search_meetings(args: dict) -> dict: + import ffp_quill + url = str(_meetings_cfg().get("mcp_url") or ffp_quill.DEFAULT_MCP_URL) + return ffp_quill.search_meetings(str(args.get("query") or ""), int(args.get("limit") or 12), url=url) + + +def _act_meeting_digest_get(args: dict) -> dict: + import ffp_meetings + return ffp_meetings.get_digest(str(args.get("meeting_id") or "")) + + +def _act_meeting_digests_list(_args: dict) -> dict: + import ffp_meetings + return ffp_meetings.list_digests() + + +def _act_meeting_process(args: dict) -> dict: + """Process one meeting now (used by the dashboard 'Process now' button).""" + import ffp_meetings + mid = str(args.get("meeting_id") or "") + if not mid: + raise ValueError("meeting_process requires args.meeting_id") + meeting = {"id": mid, "title": str(args.get("title") or ""), + "date": str(args.get("date") or ""), "url": str(args.get("url") or "")} + rec = ffp_meetings.process_meeting(meeting, grammar_fix.load_config()) + ffp_meetings.save_digest(rec) + return {"ok": True, **rec} + + +def _act_meeting_batch_run(args: dict) -> dict: + import ffp_meetings + mpr = args.get("max_per_run") + return ffp_meetings.run_batch(grammar_fix.load_config(), + max_per_run=int(mpr) if mpr else None, reason="manual") + + +def _act_meeting_batch_status(_args: dict) -> dict: + import ffp_meetings + return ffp_meetings.batch_status() + + +def _act_meeting_ask(args: dict) -> dict: + import ffp_meetings + return ffp_meetings.ask(str(args.get("meeting_id") or ""), + str(args.get("question") or ""), grammar_fix.load_config()) + + ACTIONS: dict[str, Callable[[dict], Any]] = { "status": _act_status, "start": _act_start, @@ -688,6 +745,14 @@ def _act_chat_take_staged(_args: dict) -> dict: "chat_thread_delete": _act_chat_thread_delete, "chat_stage_selection": _act_chat_stage_selection, "chat_take_staged": _act_chat_take_staged, + "quill_status": _act_quill_status, + "quill_search_meetings": _act_quill_search_meetings, + "meeting_digest_get": _act_meeting_digest_get, + "meeting_digests_list": _act_meeting_digests_list, + "meeting_process": _act_meeting_process, + "meeting_batch_run": _act_meeting_batch_run, + "meeting_batch_status": _act_meeting_batch_status, + "meeting_ask": _act_meeting_ask, "get_autostart_state": _act_get_autostart_state, "set_autostart": _act_set_autostart, "open_dashboard": _act_open_dashboard, @@ -912,6 +977,31 @@ def _watch_parent(parent_pid: int) -> None: _shutdown_event.wait(5.0) +_SCHED_INTERVAL_SECONDS = 300 + + +def _meeting_scheduler() -> None: + """Background thread: during the configured idle window, pre-compute meeting + digests so daytime reads are instant. Idempotent (run_batch skips cached + meetings); gated by ffp_meetings.should_run_batch (enabled + window + idle). + Exits promptly on shutdown via the _shutdown_event wait.""" + import datetime + + import ffp_meetings + log.info("meeting scheduler thread started (interval %ds)", _SCHED_INTERVAL_SECONDS) + while not _shutdown_event.wait(_SCHED_INTERVAL_SECONDS): + try: + cfg = grammar_fix.load_config() + ok, reason = ffp_meetings.should_run_batch( + cfg.get("meetings") or {}, datetime.datetime.now(), ffp_meetings.machine_idle_seconds() + ) + if ok: + log.info("scheduled meeting batch starting") + log.info("scheduled meeting batch result: %s", ffp_meetings.run_batch(cfg, reason="scheduled")) + except Exception as exc: + log.warning("meeting scheduler tick failed: %s", exc) + + def _setup_logging(log_level: str) -> None: level = getattr(logging, log_level.upper(), logging.INFO) @@ -957,6 +1047,9 @@ def main() -> int: if args.parent_pid > 0: threading.Thread(target=_watch_parent, args=(args.parent_pid,), daemon=True).start() + # After-hours meeting digest scheduler (no-op unless meetings.enabled + in window). + threading.Thread(target=_meeting_scheduler, daemon=True).start() + server = ThreadingHTTPServer((HOST, args.port), Handler) server.timeout = 1.0 diff --git a/scripts/ffp_meetings.py b/scripts/ffp_meetings.py new file mode 100644 index 0000000..6d8a915 --- /dev/null +++ b/scripts/ffp_meetings.py @@ -0,0 +1,386 @@ +"""After-hours meeting processing: digest cache + batch worker + scheduler logic. + +The expensive part of asking a local model about a meeting is *prefill* — a full +transcript (~7k tokens) costs ~17s before the first token on the NPU. So instead +of paying that at read time, a scheduler runs during a configured idle window +(default 17:00-21:00) and pre-computes a digest (summary / goals / action items) +for each meeting once, caching it in ``data/meeting_digests.jsonl``. Daytime +reads are then instant. + +Layout: +- digest store: load / get / save / list (upsert-rewrite, latest per meeting). +- ``process_meeting`` / ``run_batch``: fetch content from Quill (minutes + preferred, transcript fallback), one LLM call -> markdown digest, cache it. +- ``should_run_batch`` / ``machine_idle_seconds``: pure-ish scheduler gating used + by the daemon's background thread (window + idle + enabled). +- ``ask``: on-demand Q&A about one meeting (used by the dashboard). + +The LLM call is injectable for tests; it defaults to the same provider-resolved +endpoint chat/grammar use (via ffp_chat._default_llm_call). +""" + +from __future__ import annotations + +import logging +import threading +import time + +import ffp_quill +import paths as _paths + +log = logging.getLogger("ffp.meetings") + +DIGESTS_PATH = _paths.MEETING_DIGESTS_FILE + +DEFAULTS = { + "enabled": False, + "mcp_url": ffp_quill.DEFAULT_MCP_URL, + "source": "auto", # auto | minutes | transcript + "max_context_tokens": 6000, + "batch": { + "enabled": True, + "start": "17:00", + "end": "21:00", + "only_when_idle": True, + "idle_minutes": 10, + "max_per_run": 10, + }, +} + +_DIGEST_MAX_TOKENS = 600 +_ASK_MAX_TOKENS = 700 +_TEMPERATURE = 0.2 + +_batch_lock = threading.Lock() # only one batch run at a time +_io_lock = threading.Lock() # serialize digest-file read-modify-write +_status: dict = {"running": False, "last_run_at": "", "last_processed": 0, "last_errors": 0, "last_reason": ""} + + +# ---------- time helpers (mirror ffp_notifications) ----------------------------------- + +def _parse_hhmm(value: object, default_minutes: int) -> int: + try: + hh, mm = str(value).split(":") + h, m = int(hh), int(mm) + if 0 <= h <= 23 and 0 <= m <= 59: + return h * 60 + m + except (ValueError, AttributeError): + pass + return default_minutes + + +def _in_window(now_minutes: int, start_minutes: int, end_minutes: int) -> bool: + if start_minutes == end_minutes: + return False + if start_minutes < end_minutes: + return start_minutes <= now_minutes < end_minutes + return now_minutes >= start_minutes or now_minutes < end_minutes + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S") + + +def machine_idle_seconds() -> float | None: + """Seconds since the last keyboard/mouse input (Windows GetLastInputInfo). + Returns None when it can't be determined (non-Windows / API failure).""" + try: + import ctypes + from ctypes import wintypes + + class _LASTINPUTINFO(ctypes.Structure): + _fields_ = [("cbSize", wintypes.UINT), ("dwTime", wintypes.DWORD)] + + info = _LASTINPUTINFO() + info.cbSize = ctypes.sizeof(_LASTINPUTINFO) + if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(info)): + return None + millis = ctypes.windll.kernel32.GetTickCount() - info.dwTime + return max(0.0, millis / 1000.0) + except Exception: + return None + + +def should_run_batch(meetings_cfg: dict, now_dt, idle_seconds: float | None) -> tuple[bool, str]: + """Pure gate for the scheduler. now_dt is a datetime; idle_seconds may be None + (unknown -> not treated as active, so the window alone gates).""" + cfg = meetings_cfg if isinstance(meetings_cfg, dict) else {} + if not cfg.get("enabled"): + return False, "integration_disabled" + b = cfg.get("batch") if isinstance(cfg.get("batch"), dict) else {} + if not b.get("enabled", True): + return False, "batch_disabled" + nm = now_dt.hour * 60 + now_dt.minute + start = _parse_hhmm(b.get("start"), 17 * 60) + end = _parse_hhmm(b.get("end"), 21 * 60) + if not _in_window(nm, start, end): + return False, "outside_window" + if b.get("only_when_idle", True) and idle_seconds is not None: + if idle_seconds < int(b.get("idle_minutes", 10)) * 60: + return False, "machine_active" + return True, "ok" + + +# ---------- digest store -------------------------------------------------------------- + +def load_digests() -> list[dict]: + import json + if not DIGESTS_PATH.exists(): + return [] + latest: dict[str, dict] = {} + try: + with DIGESTS_PATH.open("r", encoding="utf-8", errors="replace") as f: + for raw in f: + raw = raw.strip() + if not raw: + continue + try: + row = json.loads(raw) + except Exception: + continue + mid = row.get("meeting_id") + if not mid: + continue + prev = latest.get(mid) + if prev is None or str(row.get("processed_at") or "") >= str(prev.get("processed_at") or ""): + latest[mid] = row + except OSError as exc: + log.warning("load_digests failed: %s", exc) + return [] + return sorted(latest.values(), key=lambda r: str(r.get("processed_at") or ""), reverse=True) + + +def save_digest(rec: dict) -> None: + """Upsert by meeting_id and rewrite the file (bounded; digests are few). + Serialized by _io_lock so concurrent processors can't lose each other's + writes (the digest file is separate from config, so no global lock needed).""" + import json + with _io_lock: + digests = [d for d in load_digests() if d.get("meeting_id") != rec.get("meeting_id")] + digests.insert(0, rec) + try: + DIGESTS_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = DIGESTS_PATH.with_suffix(".jsonl.tmp") + with tmp.open("w", encoding="utf-8") as f: + for d in digests: + f.write(json.dumps(d, ensure_ascii=False) + "\n") + tmp.replace(DIGESTS_PATH) + except OSError as exc: + log.warning("save_digest failed: %s", exc) + + +def digest_exists(meeting_id: str) -> bool: + return any(d.get("meeting_id") == meeting_id for d in load_digests()) + + +def get_digest(meeting_id: str) -> dict: + for d in load_digests(): + if d.get("meeting_id") == str(meeting_id or ""): + return {"found": True, **d} + return {"found": False, "meeting_id": str(meeting_id or "")} + + +def list_digests() -> dict: + rows = [ + {k: d.get(k) for k in ("meeting_id", "title", "date", "url", "processed_at", "source", "seconds")} + for d in load_digests() + ] + return {"digests": rows, "count": len(rows)} + + +# ---------- LLM + content ------------------------------------------------------------- + +def _resolve_llm_call(llm_call): + if llm_call is not None: + return llm_call + import ffp_chat + return ffp_chat._default_llm_call + + +def _provider_model() -> tuple[str, str]: + try: + import grammar_fix + return str(getattr(grammar_fix, "LLM_PROVIDER", "")), str(getattr(grammar_fix, "FLM_MODEL", "")) + except Exception: + return "", "" + + +def _fetch_content(meeting_id: str, cfg_meetings: dict, client) -> tuple[str, str]: + """Return (content, source). Prefers minutes unless source='transcript'.""" + source_pref = str(cfg_meetings.get("source") or "auto") + max_chars = max(1000, int(cfg_meetings.get("max_context_tokens") or 6000) * 4) + content, used = "", "" + if source_pref != "transcript": + content = ffp_quill.get_minutes(meeting_id, client=client) + if content: + used = "minutes" + if not content: + content = ffp_quill.get_transcript(meeting_id, client=client) + used = "transcript" if content else used + if len(content) > max_chars: + content = content[:max_chars] + "\n…[truncated]" + return content, used + + +_DIGEST_SYSTEM = ( + "You write concise, factual digests of meetings for later reference. Use ONLY " + "the provided content; never invent details. If a section has nothing to draw " + "on, write '- (not discussed)'." +) + + +def _digest_prompt(title: str, date: str, content: str) -> list[dict]: + user = ( + f"MEETING: {title} ({date})\n\nCONTENT:\n{content}\n\n" + "Write the digest using EXACTLY these three markdown sections, nothing else:\n" + "## Summary\n- 3-5 short bullets\n" + "## Goals\n- what the meeting set out to decide or achieve\n" + "## Action items\n- one bullet per item as '[owner] task' (use [unassigned] if no owner stated)\n" + ) + return [{"role": "system", "content": _DIGEST_SYSTEM}, {"role": "user", "content": user}] + + +def process_meeting(meeting: dict, cfg: dict, *, client=None, llm_call=None) -> dict: + """Fetch one meeting's content and produce + return a digest record.""" + mcfg = cfg.get("meetings") if isinstance(cfg.get("meetings"), dict) else {} + mid = str(meeting.get("id") or "") + if not mid: + raise ValueError("meeting has no id") + c = client or ffp_quill.QuillClient(str(mcfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL)) + content, source = _fetch_content(mid, mcfg, c) + if not content: + raise RuntimeError("no minutes or transcript available for meeting") + call = _resolve_llm_call(llm_call) + t0 = time.time() + digest_md = str(call(_digest_prompt(meeting.get("title") or "", meeting.get("date") or "", content)) or "").strip() + seconds = round(time.time() - t0, 2) + provider, model = _provider_model() + return { + "meeting_id": mid, + "title": meeting.get("title") or "", + "date": meeting.get("date") or "", + "url": meeting.get("url") or "", + "processed_at": _now_iso(), + "provider": provider, + "model": model, + "source": source, + "context_chars": len(content), + "seconds": seconds, + "digest_md": digest_md, + } + + +def run_batch(cfg: dict, *, llm_call=None, client=None, max_per_run=None, reason: str = "manual") -> dict: + """Process up to N undigested recent meetings. Idempotent (skips cached).""" + mcfg = cfg.get("meetings") if isinstance(cfg.get("meetings"), dict) else {} + if not mcfg.get("enabled"): + return {"ok": False, "error": "Quill integration is disabled", "processed": 0} + if not _batch_lock.acquire(blocking=False): + return {"ok": False, "error": "a batch run is already in progress", "processed": 0} + try: + _status["running"] = True + url = str(mcfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL) + c = client or ffp_quill.QuillClient(url) + if not c.connect(): + return {"ok": False, "error": "Quill MCP server is not reachable", "processed": 0} + b = mcfg.get("batch") if isinstance(mcfg.get("batch"), dict) else {} + cap = int(max_per_run if max_per_run is not None else b.get("max_per_run", 10)) + cap = max(1, min(cap, 50)) + + todo: list[dict] = [] + seen: set[str] = set() + offset = 0 + while len(todo) < cap and offset <= 120: + page = ffp_quill.list_recent_meetings(limit=30, offset=offset, client=c) + if not page: + break + for mt in page: + mid = mt.get("id") + if mid and mid not in seen: + seen.add(mid) + if not digest_exists(mid): + todo.append(mt) + if len(todo) >= cap: + break + offset += 30 + + processed, errors = 0, [] + for mt in todo: + try: + save_digest(process_meeting(mt, cfg, client=c, llm_call=llm_call)) + processed += 1 + except Exception as exc: + log.warning("digest failed for %s: %s", mt.get("id"), exc) + errors.append({"meeting_id": mt.get("id"), "error": str(exc)}) + _status.update({ + "last_run_at": _now_iso(), "last_processed": processed, + "last_errors": len(errors), "last_reason": reason, + }) + return {"ok": True, "processed": processed, "errors": errors, "queued": len(todo)} + finally: + _status["running"] = False + _batch_lock.release() + + +def batch_status() -> dict: + return {**_status, "total_digests": len(load_digests())} + + +# ---------- on-demand Q&A about one meeting ------------------------------------------- + +_ASK_SYSTEM = ( + "You answer questions about a single meeting using ONLY the provided content. " + "Be concise and specific. If the content does not answer the question, say so." +) + + +def ask(meeting_id: str, question: str, cfg: dict, *, client=None, llm_call=None) -> dict: + mcfg = cfg.get("meetings") if isinstance(cfg.get("meetings"), dict) else {} + if not mcfg.get("enabled"): + return {"ok": False, "error": "Quill integration is disabled"} + question = str(question or "").strip() + if not question: + raise ValueError("empty question") + url = str(mcfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL) + c = client or ffp_quill.QuillClient(url) + if not c.connect(): + return {"ok": False, "error": "Quill MCP server is not reachable"} + # Prefer a cached digest as the grounding context (cheap); fall back to live content. + cached = get_digest(meeting_id) + title = cached.get("title") or "" + date = cached.get("date") or "" + if cached.get("found") and cached.get("digest_md"): + content, source = cached["digest_md"], "digest" + else: + content, source = _fetch_content(str(meeting_id), mcfg, c) + if not content: + return {"ok": False, "error": "no content available for this meeting"} + call = _resolve_llm_call(llm_call) + msgs = [ + {"role": "system", "content": _ASK_SYSTEM}, + {"role": "user", "content": f"MEETING: {title} ({date})\n\nCONTENT:\n{content}\n\nQUESTION: {question}"}, + ] + t0 = time.time() + answer = str(call(msgs) or "").strip() + return {"ok": True, "answer": answer, "source": source, "seconds": round(time.time() - t0, 2)} + + +# ---------- dashboard snapshot -------------------------------------------------------- + +def config_snapshot(meetings_cfg) -> dict: + cfg = meetings_cfg if isinstance(meetings_cfg, dict) else {} + b = cfg.get("batch") if isinstance(cfg.get("batch"), dict) else {} + return { + "enabled": bool(cfg.get("enabled", False)), + "mcp_url": str(cfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL), + "source": str(cfg.get("source") or "auto"), + "max_context_tokens": int(cfg.get("max_context_tokens") or 6000), + "batch": { + "enabled": bool(b.get("enabled", True)), + "start": str(b.get("start") or "17:00"), + "end": str(b.get("end") or "21:00"), + "only_when_idle": bool(b.get("only_when_idle", True)), + "idle_minutes": int(b.get("idle_minutes") or 10), + "max_per_run": int(b.get("max_per_run") or 10), + }, + } diff --git a/scripts/ffp_quill.py b/scripts/ffp_quill.py new file mode 100644 index 0000000..2ab458d --- /dev/null +++ b/scripts/ffp_quill.py @@ -0,0 +1,182 @@ +"""Minimal MCP-over-HTTP client for the Quill note-taking app. + +Quill exposes a Model Context Protocol server (Streamable HTTP transport) at +``http://127.0.0.1:19532/mcp`` by default. This module speaks just enough of +that protocol — ``initialize`` -> ``notifications/initialized`` -> ``tools/call`` +— to read meetings, transcripts, and minutes, then parses Quill's XML-ish tool +output into plain dicts the daemon/dashboard can use. + +Stdlib only. All network access is loopback (the URL is validated as loopback +in ffp_config before it reaches here). Designed to fail soft: if Quill isn't +running, callers get ``reachable=False`` / empty results rather than exceptions. +""" + +from __future__ import annotations + +import html +import json +import logging +import re +import urllib.error +import urllib.request + +log = logging.getLogger("ffp.quill") + +DEFAULT_MCP_URL = "http://127.0.0.1:19532/mcp" +PROTOCOL_VERSION = "2025-06-18" + + +def _parse_sse(body: str) -> list[dict]: + """Extract JSON objects from SSE ``data:`` lines (Quill replies as SSE).""" + out: list[dict] = [] + for line in body.splitlines(): + line = line.strip() + if line.startswith("data:"): + payload = line[5:].strip() + if payload and payload != "[DONE]": + try: + out.append(json.loads(payload)) + except json.JSONDecodeError: + pass + return out + + +class QuillClient: + """Stateful MCP session. Reuse one instance across calls in a single run.""" + + def __init__(self, url: str = DEFAULT_MCP_URL, timeout: int = 30): + self.url = url or DEFAULT_MCP_URL + self.timeout = timeout + self.session_id: str | None = None + self.server_info: dict = {} + + def _post(self, method: str, params: dict | None, *, notification: bool = False) -> dict | None: + body: dict = {"jsonrpc": "2.0", "method": method} + if not notification: + body["id"] = 1 + if params is not None: + body["params"] = params + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if self.session_id: + headers["Mcp-Session-Id"] = self.session_id + req = urllib.request.Request(self.url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + sid = resp.headers.get("Mcp-Session-Id") + if sid: + self.session_id = sid + ctype = resp.headers.get("Content-Type", "") + raw = resp.read().decode("utf-8", "replace") + if notification: + return None + objs = _parse_sse(raw) if "text/event-stream" in ctype else ([json.loads(raw)] if raw.strip() else []) + for o in objs: + if "result" in o or "error" in o: + return o + return objs[0] if objs else None + + def connect(self) -> bool: + """Run the MCP handshake. Returns True if Quill answered.""" + try: + init = self._post("initialize", { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "flowkey", "version": "1.0"}, + }) + self.server_info = ((init or {}).get("result") or {}).get("serverInfo") or {} + self._post("notifications/initialized", None, notification=True) + return True + except (urllib.error.URLError, TimeoutError, OSError) as exc: + log.info("Quill not reachable at %s: %s", self.url, exc) + return False + except Exception as exc: # malformed handshake — treat as unreachable + log.warning("Quill handshake failed: %s", exc) + return False + + def call_tool(self, name: str, arguments: dict) -> str: + """Call an MCP tool, return its text content ('' on error/empty).""" + if not self.session_id and not self.connect(): + return "" + try: + res = self._post("tools/call", {"name": name, "arguments": arguments}) + except Exception as exc: + log.warning("Quill tool %s failed: %s", name, exc) + return "" + content = ((res or {}).get("result") or {}).get("content") or [] + return "\n".join(c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text") + + +# ---------- parsing helpers (Quill's XML-ish tool output -> dicts) -------------------- + +_MEETING_RE = re.compile(r"]*)>(.*?)", re.DOTALL) +_ATTR_RE = re.compile(r'(\w+)="([^"]*)"') +_TITLE_RE = re.compile(r"(.*?)", re.DOTALL) + + +def _parse_meetings(text: str) -> list[dict]: + meetings: list[dict] = [] + for attr_blob, inner in _MEETING_RE.findall(text or ""): + attrs = dict(_ATTR_RE.findall(attr_blob)) + tm = _TITLE_RE.search(inner) + meetings.append({ + "id": attrs.get("id", ""), + "title": html.unescape(tm.group(1).strip() if tm else "") or "(untitled)", + "date": attrs.get("date", ""), + "duration": attrs.get("duration", ""), + "participants": html.unescape(attrs.get("participants", "")), + "tags": attrs.get("tags", ""), + "url": attrs.get("url", ""), + }) + return meetings + + +def clean_text(raw: str) -> str: + """Strip a transcript/minutes wrapper tag and normalize entities/smart quotes.""" + inner = re.sub(r"]*>", "", raw or "") + inner = html.unescape(inner) + inner = inner.replace("″", '"').replace("′", "'").replace("•", "") + return inner.strip() + + +# ---------- high-level operations ----------------------------------------------------- + +def status(url: str = DEFAULT_MCP_URL) -> dict: + """Reachability + server identity, for the dashboard.""" + c = QuillClient(url, timeout=6) + if not c.connect(): + return {"reachable": False, "url": url} + return { + "reachable": True, + "url": url, + "server": c.server_info.get("name") or "quill", + "server_version": c.server_info.get("version") or "", + } + + +def search_meetings(query: str, limit: int = 10, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> dict: + c = client or QuillClient(url) + args: dict = {"limit": max(1, min(int(limit or 10), 30))} + if query: + args["query"] = str(query) + text = c.call_tool("search_meetings", args) + return {"meetings": _parse_meetings(text), "reachable": bool(c.session_id)} + + +def list_recent_meetings(limit: int = 30, offset: int = 0, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> list[dict]: + c = client or QuillClient(url) + args: dict = {"limit": max(1, min(int(limit or 30), 30))} + if offset: + args["offset"] = int(offset) + return _parse_meetings(c.call_tool("search_meetings", args)) + + +def get_minutes(meeting_id: str, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> str: + c = client or QuillClient(url) + text = c.call_tool("get_minutes", {"meeting_id": meeting_id}) + if not text or "No minutes found" in text: + return "" + return clean_text(text) + + +def get_transcript(meeting_id: str, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> str: + c = client or QuillClient(url) + return clean_text(c.call_tool("get_transcript", {"id": meeting_id})) diff --git a/scripts/grammar_fix.py b/scripts/grammar_fix.py index 5acbd65..d8cf82e 100644 --- a/scripts/grammar_fix.py +++ b/scripts/grammar_fix.py @@ -21,6 +21,7 @@ import ffp_config import ffp_flm_server import ffp_llm_client +import ffp_meetings import ffp_notifications import ffp_provider_runtime import ffp_provider_status @@ -828,6 +829,7 @@ def build_config_snapshot() -> dict: "ask_chat": str(hotkeys_cfg.get("ask_chat") or "^+a"), }, "notifications": ffp_notifications.snapshot(cfg.get("notifications")), + "meetings": ffp_meetings.config_snapshot(cfg.get("meetings")), } diff --git a/scripts/paths.py b/scripts/paths.py index 246fc21..c4ba91e 100644 --- a/scripts/paths.py +++ b/scripts/paths.py @@ -160,6 +160,7 @@ def ensure_dirs() -> None: PROMPT_HISTORY_FILE: Path = DATA_DIR / "prompt_history.jsonl" GRAMMAR_HISTORY_FILE: Path = DATA_DIR / "grammar_fix_history.jsonl" CHAT_THREADS_FILE: Path = DATA_DIR / "chat_threads.jsonl" +MEETING_DIGESTS_FILE: Path = DATA_DIR / "meeting_digests.jsonl" FLM_PID_FILE: Path = DATA_DIR / "flm_server.pid" # Markers (tiny presence-only files) diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index 06bf2db..63d6cd2 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -634,6 +634,7 @@ async function loadConfig() { const tone = (cfg.tone || {}).preset || "formal"; document.querySelectorAll('input[name="tone"]').forEach((r) => (r.checked = r.value === tone)); populateNotifications(cfg.notifications || {}); + populateMeetings(cfg.meetings || {}); setStatus("config-status", ""); } catch (e) { setStatus("config-status", `Load failed: ${e.message}`, false); @@ -843,6 +844,7 @@ async function saveConfig() { modes: { tone: { preset: tone ? tone.value : "formal" } }, hotkeys, notifications: notificationsPatch(), + meetings: meetingsPatch(), }; try { await action("apply_config_patch", { patch }); @@ -1159,6 +1161,182 @@ async function sendChat() { } } +// ---- Meetings (Quill via MCP; after-hours digests) ------------------------- +// Search meetings, read a cached digest (summary / goals / action items), or +// generate one on demand. All data comes from the daemon's quill_*/meeting_* +// actions; the daemon talks MCP to the local Quill app. CSP-safe DOM only. +let currentMeeting = null; +let digestIds = new Set(); + +async function loadMeetings() { + const st = $("mtg-status"); + try { + const s = await action("quill_status"); + st.textContent = s.reachable + ? `Quill ${s.server_version || ""} connected`.trim() + : "Quill not reachable — enable it in Config and make sure Quill is running"; + st.className = s.reachable ? "muted small ok" : "muted small bad"; + } catch (e) { + st.textContent = `status unavailable: ${e.message}`; + st.className = "muted small bad"; + } + try { + const d = await action("meeting_digests_list"); + digestIds = new Set((d.digests || []).map((x) => x.meeting_id)); + } catch { + digestIds = new Set(); + } + searchMeetings(); +} + +async function searchMeetings() { + const q = $("mtg-query").value.trim(); + const body = $("mtg-results"); + body.replaceChildren(); + try { + const r = await action("quill_search_meetings", { query: q, limit: 15 }); + const meetings = r.meetings || []; + $("mtg-empty").hidden = meetings.length > 0; + for (const m of meetings) { + const tr = document.createElement("tr"); + tr.className = "mtg-row"; + tr.style.cursor = "pointer"; + tr.dataset.id = m.id; + tr.dataset.title = m.title || ""; + tr.dataset.date = m.date || ""; + tr.dataset.url = m.url || ""; + const cells = [m.title || "(untitled)", (m.date || "").slice(0, 10), m.participants || "", digestIds.has(m.id) ? "✓" : "—"]; + for (const c of cells) { + const td = document.createElement("td"); + td.textContent = c; + tr.append(td); + } + body.append(tr); + } + } catch (e) { + $("mtg-empty").hidden = true; + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 4; + td.textContent = `Search failed: ${e.message}`; + tr.append(td); + body.append(tr); + } +} + +function openMeeting(row) { + currentMeeting = { id: row.dataset.id, title: row.dataset.title, date: row.dataset.date, url: row.dataset.url }; + $("mtg-reader").hidden = false; + $("mtg-title").textContent = currentMeeting.title || "Meeting"; + $("mtg-meta").textContent = (currentMeeting.date || "").slice(0, 16).replace("T", " "); + const link = $("mtg-link"); + if (currentMeeting.url) { link.href = currentMeeting.url; link.hidden = false; } else { link.hidden = true; } + $("mtg-answer").hidden = true; + $("mtg-ask-input").value = ""; + $("mtg-ask-status").textContent = ""; + loadDigest(); +} + +async function loadDigest() { + const body = $("mtg-digest"); + body.textContent = "Loading…"; + $("mtg-process-status").textContent = ""; + try { + const d = await action("meeting_digest_get", { meeting_id: currentMeeting.id }); + if (d.found) { + body.textContent = d.digest_md || "(empty digest)"; + $("mtg-process-status").textContent = `cached ${(d.processed_at || "").replace("T", " ")} · ${d.source} · ${d.seconds}s`; + } else { + body.textContent = "Not processed yet. Click “Process now” to generate a summary + action items on the local model, or wait for the after-hours batch."; + } + } catch (e) { + body.textContent = `Failed: ${e.message}`; + } +} + +async function processMeetingNow() { + if (!currentMeeting) return; + $("mtg-process-status").textContent = "Processing on the local model… (first token can take ~15s on a full transcript)"; + try { + const r = await action("meeting_process", { + meeting_id: currentMeeting.id, title: currentMeeting.title, date: currentMeeting.date, url: currentMeeting.url, + }); + $("mtg-digest").textContent = r.digest_md || "(empty)"; + $("mtg-process-status").textContent = `done · ${r.source} · ${r.seconds}s`; + digestIds.add(currentMeeting.id); + } catch (e) { + $("mtg-process-status").textContent = `⚠ ${e.message}`; + } +} + +async function askMeeting() { + if (!currentMeeting) return; + const q = $("mtg-ask-input").value.trim(); + if (!q) return; + const ans = $("mtg-answer"); + ans.hidden = false; + ans.textContent = "Thinking on the local model…"; + $("mtg-ask-status").textContent = ""; + try { + const r = await action("meeting_ask", { meeting_id: currentMeeting.id, question: q }); + if (r.ok) { + ans.textContent = r.answer || "(no answer)"; + $("mtg-ask-status").textContent = `${r.source} · ${r.seconds}s`; + } else { + ans.textContent = `⚠ ${r.error || "failed"}`; + } + } catch (e) { + ans.textContent = `⚠ ${e.message}`; + } +} + +// Meetings settings <-> the Config tab inputs. +function populateMeetings(m) { + m = m || {}; + $("mtg-enabled").checked = !!m.enabled; + $("mtg-url").value = m.mcp_url || "http://127.0.0.1:19532/mcp"; + $("mtg-source").value = m.source || "auto"; + $("mtg-maxctx").value = m.max_context_tokens ?? 6000; + const b = m.batch || {}; + $("mtg-batch-enabled").checked = b.enabled !== false; + $("mtg-start").value = b.start || "17:00"; + $("mtg-end").value = b.end || "21:00"; + $("mtg-idle").checked = b.only_when_idle !== false; + $("mtg-idle-min").value = b.idle_minutes ?? 10; + $("mtg-maxrun").value = b.max_per_run ?? 10; +} + +function meetingsPatch() { + const ctx = Number($("mtg-maxctx").value); + return { + enabled: $("mtg-enabled").checked, + mcp_url: $("mtg-url").value.trim() || "http://127.0.0.1:19532/mcp", + source: $("mtg-source").value, + max_context_tokens: Number.isFinite(ctx) && ctx > 0 ? ctx : 6000, + batch: { + enabled: $("mtg-batch-enabled").checked, + start: $("mtg-start").value || "17:00", + end: $("mtg-end").value || "21:00", + only_when_idle: $("mtg-idle").checked, + idle_minutes: Number($("mtg-idle-min").value) || 0, + max_per_run: Number($("mtg-maxrun").value) || 10, + }, + }; +} + +async function runBatchNow() { + const s = $("mtg-run-status"); + s.textContent = "Running… (this processes on the local model; may take a while)"; + try { + const r = await action("meeting_batch_run", {}); + s.textContent = r.ok + ? `processed ${r.processed} of ${r.queued} queued${r.errors && r.errors.length ? `, ${r.errors.length} errors` : ""}` + : `⚠ ${r.error}`; + } catch (e) { + s.textContent = `⚠ ${e.message}`; + } +} + // ---- Tabs & refresh -------------------------------------------------------- const TAB_LOADERS = { @@ -1167,6 +1345,7 @@ const TAB_LOADERS = { telemetry: loadTelemetry, history: loadHistory, notes: loadNotes, + meetings: loadMeetings, config: loadConfig, benchmark: loadBenchmark, }; @@ -1218,6 +1397,17 @@ document.addEventListener("DOMContentLoaded", () => { $("nr-move").addEventListener("click", moveNoteToBucket); $("nr-delete").addEventListener("click", deleteCurrentNote); $("nr-close").addEventListener("click", () => { $("note-reader").hidden = true; }); + $("mtg-search-btn").addEventListener("click", searchMeetings); + $("mtg-query").addEventListener("keydown", (e) => { if (e.key === "Enter") searchMeetings(); }); + $("mtg-results").addEventListener("click", (e) => { + const row = e.target.closest(".mtg-row"); + if (row) openMeeting(row); + }); + $("mtg-close").addEventListener("click", () => { $("mtg-reader").hidden = true; }); + $("mtg-process").addEventListener("click", processMeetingNow); + $("mtg-ask-btn").addEventListener("click", askMeeting); + $("mtg-ask-input").addEventListener("keydown", (e) => { if (e.key === "Enter") askMeeting(); }); + $("mtg-run-now").addEventListener("click", runBatchNow); $("config-save").addEventListener("click", saveConfig); $("config-revert").addEventListener("click", loadConfig); $("cm-select").addEventListener("change", fillCustomModeForm); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index 9a21a60..c887bb4 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -29,6 +29,7 @@

Flowkey

+ @@ -193,6 +194,43 @@

LLM behavior

+
+
+
+

Meetings checking Quill…

+
+ + +
+ + + +
TitleDateParticipantsDigest
+ +

Click a meeting to read its cached digest, generate one now, or ask a question about it.

+
+ +
+
+
@@ -309,6 +347,35 @@

Per-event

+
+

Meetings & after-hours processing

+

Connect the local Quill note app over MCP to search meetings and ask about them. During the idle window below, Flowkey pre-computes each meeting's summary + action items on the local model so daytime reads are instant (a full transcript costs ~15 s of prefill — paid once, after hours).

+ +
+
+ +
+
caps what's fed to the model (bigger = slower prefill)
+

After-hours batch

+ +
+ + + + +
+ +
+
+
+ + +
+
diff --git a/setup/defaults/grammar_hotkey.config.example.json b/setup/defaults/grammar_hotkey.config.example.json index 22c7837..398155c 100644 --- a/setup/defaults/grammar_hotkey.config.example.json +++ b/setup/defaults/grammar_hotkey.config.example.json @@ -38,6 +38,20 @@ "action_result": { "enabled": true } } }, + "meetings": { + "enabled": false, + "mcp_url": "http://127.0.0.1:19532/mcp", + "source": "auto", + "max_context_tokens": 6000, + "batch": { + "enabled": true, + "start": "17:00", + "end": "21:00", + "only_when_idle": true, + "idle_minutes": 10, + "max_per_run": 10 + } + }, "hotkeys": { "grammar_fix": "^+g", "open_chat": "^!c", diff --git a/tests/test_ffp_config.py b/tests/test_ffp_config.py index de47794..18f4954 100644 --- a/tests/test_ffp_config.py +++ b/tests/test_ffp_config.py @@ -373,3 +373,47 @@ def test_filter_config_patch_notifications_clamps_dedupe(): {"notifications": {"dedupe_seconds": 999999}} ) assert filtered["notifications"]["dedupe_seconds"] == 3600.0 + + +def test_filter_config_patch_meetings_full(): + patch = { + "meetings": { + "enabled": True, + "mcp_url": "http://127.0.0.1:19532/mcp", + "source": "minutes", + "max_context_tokens": 4000, + "batch": { + "enabled": True, "start": "18:00", "end": "23:00", + "only_when_idle": False, "idle_minutes": 5, "max_per_run": 8, + }, + } + } + filtered = ffp_config.filter_config_patch(patch) + assert filtered == patch + + +def test_filter_config_patch_meetings_rejects_non_loopback_url(): + with pytest.raises(ValueError, match="loopback"): + ffp_config.filter_config_patch({"meetings": {"mcp_url": "http://evil.example.com/mcp"}}) + + +def test_filter_config_patch_meetings_rejects_junk(): + filtered = ffp_config.filter_config_patch({ + "meetings": { + "source": "bogus", # not in enum -> dropped + "max_context_tokens": "lots", # not int -> dropped + "evil": 1, # unknown -> dropped + "batch": {"start": "99:99", "max_per_run": 999, "junk": 1}, + } + })["meetings"] + assert "source" not in filtered + assert "max_context_tokens" not in filtered + assert "evil" not in filtered + assert "start" not in filtered["batch"] # bad HH:MM dropped + assert filtered["batch"]["max_per_run"] == 50 # clamped to max + assert "junk" not in filtered["batch"] + + +def test_filter_config_patch_meetings_clamps_context(): + filtered = ffp_config.filter_config_patch({"meetings": {"max_context_tokens": 100}}) + assert filtered["meetings"]["max_context_tokens"] == 500 # clamped to min diff --git a/tests/test_ffp_daemon.py b/tests/test_ffp_daemon.py index 539df8f..4da733e 100644 --- a/tests/test_ffp_daemon.py +++ b/tests/test_ffp_daemon.py @@ -70,15 +70,26 @@ def test_actions_count_and_expected_names(daemon_module): # chat_thread_delete/chat_stage_selection/chat_take_staged -> 59; retiring the # tkinter popup removed chat_send_selection/chat_reload/chat_restart -> 56; # richer notes view added note_get/note_move/note_delete -> 59; - # notifications settings added notify_gate/notifications_log -> 61. - assert len(daemon_module.ACTIONS) == 61 + # notifications settings added notify_gate/notifications_log -> 61; + # Quill meetings added quill_status/quill_search_meetings/meeting_digest_get/ + # meeting_digests_list/meeting_process/meeting_batch_run/meeting_batch_status/ + # meeting_ask -> 69. + assert len(daemon_module.ACTIONS) == 69 for a in ("chat_threads_list", "chat_thread_get", "chat_send", "chat_thread_delete", "chat_stage_selection", "chat_take_staged", "note_get", "note_move", "note_delete", - "notify_gate", "notifications_log"): + "notify_gate", "notifications_log", + "quill_status", "quill_search_meetings", "meeting_digest_get", + "meeting_digests_list", "meeting_process", "meeting_batch_run", + "meeting_batch_status", "meeting_ask"): assert a in daemon_module.ACTIONS # notify_gate writes the log + dedupe state, so it must be a WRITE action. assert "notify_gate" in daemon_module._WRITE_ACTIONS + # The meeting actions manage their own locking + write a separate file, so + # they are intentionally NOT under the global config write-lock (a long batch + # must not block config saves / notifications). + for a in ("meeting_process", "meeting_batch_run", "meeting_ask"): + assert a not in daemon_module._WRITE_ACTIONS # popup-era socket actions are gone (chat is daemon-backed now) for a in ("chat_send_selection", "chat_reload", "chat_restart"): assert a not in daemon_module.ACTIONS diff --git a/tests/test_ffp_meetings.py b/tests/test_ffp_meetings.py new file mode 100644 index 0000000..e2d51ba --- /dev/null +++ b/tests/test_ffp_meetings.py @@ -0,0 +1,199 @@ +"""Tests for ffp_meetings: scheduler gating, digest store, batch processing +(with a fake Quill client + injected LLM), on-demand ask, and config snapshot. +""" + +from __future__ import annotations + +import datetime + +import ffp_meetings as M +import pytest + + +@pytest.fixture(autouse=True) +def _tmp_digests(tmp_path, monkeypatch): + monkeypatch.setattr(M, "DIGESTS_PATH", tmp_path / "meeting_digests.jsonl") + + +class FakeQuill: + """Stand-in for ffp_quill.QuillClient — no network.""" + + def __init__(self, meetings, minutes="", transcript="Some transcript text."): + self.session_id = "fake" + self._meetings = meetings + self._minutes = minutes + self._transcript = transcript + self.calls: list = [] + + def connect(self): + return True + + def call_tool(self, name, arguments): + self.calls.append((name, arguments)) + if name == "search_meetings": + offset = arguments.get("offset", 0) + if offset: # single page of results, like a small vault + return "" + rows = "".join( + f'' + f'{m["title"]}' + for m in self._meetings + ) + return f"{rows}" + if name == "get_minutes": + return self._minutes or "No minutes found for this meeting" + if name == "get_transcript": + return f"{self._transcript}" + return "" + + +def _cfg(enabled=True, **batch): + b = {"enabled": True, "start": "17:00", "end": "21:00", "only_when_idle": True, "idle_minutes": 10, "max_per_run": 10} + b.update(batch) + return {"meetings": {"enabled": enabled, "mcp_url": "http://127.0.0.1:19532/mcp", "source": "auto", "max_context_tokens": 6000, "batch": b}} + + +# ---------- scheduler gate ------------------------------------------------------------ + +def _at(h, m, idle, cfg=None): + mcfg = (cfg or _cfg())["meetings"] + return M.should_run_batch(mcfg, datetime.datetime(2026, 6, 18, h, m), idle) + + +def test_should_run_in_window_idle(): + assert _at(18, 0, 999) == (True, "ok") + + +def test_should_run_blocked_when_active(): + assert _at(18, 0, 60) == (False, "machine_active") + + +def test_should_run_outside_window(): + assert _at(12, 0, 999) == (False, "outside_window") + + +def test_should_run_idle_unknown_allows(): + # idle_seconds None (detection failed) -> window alone gates + assert _at(18, 0, None) == (True, "ok") + + +def test_should_run_disabled_integration(): + assert M.should_run_batch({"enabled": False}, datetime.datetime(2026, 6, 18, 18, 0), 999) == (False, "integration_disabled") + + +def test_should_run_batch_disabled(): + mcfg = {"enabled": True, "batch": {"enabled": False, "start": "17:00", "end": "21:00"}} + assert M.should_run_batch(mcfg, datetime.datetime(2026, 6, 18, 18, 0), 999) == (False, "batch_disabled") + + +def test_window_wraps_midnight(): + cfg = _cfg(start="22:00", end="07:00") + assert _at(23, 0, 999, cfg) == (True, "ok") + assert _at(2, 0, 999, cfg) == (True, "ok") + assert _at(12, 0, 999, cfg) == (False, "outside_window") + + +def test_only_when_idle_off_runs_active(): + assert _at(18, 0, 5, _cfg(only_when_idle=False)) == (True, "ok") + + +# ---------- digest store -------------------------------------------------------------- + +def test_digest_store_upsert_and_get(): + M.save_digest({"meeting_id": "m1", "title": "One", "processed_at": "2026-06-18T18:00:00", "digest_md": "v1"}) + assert M.digest_exists("m1") + assert M.get_digest("m1")["digest_md"] == "v1" + # upsert replaces, not duplicates + M.save_digest({"meeting_id": "m1", "title": "One", "processed_at": "2026-06-18T19:00:00", "digest_md": "v2"}) + assert len(M.load_digests()) == 1 + assert M.get_digest("m1")["digest_md"] == "v2" + assert M.get_digest("nope") == {"found": False, "meeting_id": "nope"} + + +def test_digests_list(): + M.save_digest({"meeting_id": "a", "title": "A", "processed_at": "2026-06-18T18:00:00", "source": "minutes", "seconds": 1.0}) + out = M.list_digests() + assert out["count"] == 1 + assert out["digests"][0]["meeting_id"] == "a" + assert "digest_md" not in out["digests"][0] # list is summary-only + + +# ---------- process / batch ----------------------------------------------------------- + +def test_process_meeting_uses_minutes_when_available(): + fake = FakeQuill([], minutes="## Notes\n- decided X") + calls = {} + def fake_llm(messages): + calls["content"] = messages[-1]["content"] + return "## Summary\n- ok" + rec = M.process_meeting({"id": "m9", "title": "T", "date": "2026-06-18T18:00:00Z"}, _cfg(), client=fake, llm_call=fake_llm) + assert rec["source"] == "minutes" + assert rec["digest_md"] == "## Summary\n- ok" + assert "decided X" in calls["content"] # minutes were fed to the model + + +def test_process_meeting_falls_back_to_transcript(): + fake = FakeQuill([], minutes="", transcript="we will ship friday") + rec = M.process_meeting({"id": "m8", "title": "T", "date": ""}, _cfg(), client=fake, llm_call=lambda m: "digest") + assert rec["source"] == "transcript" + + +def test_run_batch_processes_undigested_and_skips_cached(): + meetings = [{"id": "x1", "title": "M1", "date": "2026-06-18T18:00:00Z"}, + {"id": "x2", "title": "M2", "date": "2026-06-17T18:00:00Z"}] + fake = FakeQuill(meetings, minutes="## m\n- a") + M.save_digest({"meeting_id": "x1", "title": "M1", "processed_at": "2026-06-18T18:00:00", "digest_md": "old"}) + res = M.run_batch(_cfg(), client=fake, llm_call=lambda m: "fresh digest") + assert res["ok"] is True + assert res["processed"] == 1 # only x2 (x1 already cached) + assert M.get_digest("x2")["digest_md"] == "fresh digest" + assert M.get_digest("x1")["digest_md"] == "old" # untouched + + +def test_run_batch_disabled_integration(): + res = M.run_batch({"meetings": {"enabled": False}}, client=FakeQuill([]), llm_call=lambda m: "x") + assert res["ok"] is False and res["processed"] == 0 + + +def test_run_batch_respects_max_per_run(): + meetings = [{"id": f"id{i}", "title": f"M{i}", "date": "2026-06-18T18:00:00Z"} for i in range(5)] + fake = FakeQuill(meetings, minutes="## m\n- a") + res = M.run_batch(_cfg(), client=fake, llm_call=lambda m: "d", max_per_run=2) + assert res["processed"] == 2 + assert M.batch_status()["total_digests"] == 2 + + +# ---------- ask ----------------------------------------------------------------------- + +def test_ask_prefers_cached_digest(): + M.save_digest({"meeting_id": "q1", "title": "Q", "date": "", "processed_at": "2026-06-18T18:00:00", "digest_md": "## Summary\n- cached"}) + fake = FakeQuill([]) + seen = {} + def fake_llm(messages): + seen["ctx"] = messages[-1]["content"] + return "answer from digest" + out = M.ask("q1", "what happened?", _cfg(), client=fake, llm_call=fake_llm) + assert out["ok"] is True + assert out["source"] == "digest" + assert "cached" in seen["ctx"] # grounded on the cheap cached digest + assert out["answer"] == "answer from digest" + + +def test_ask_empty_question_raises(): + with pytest.raises(ValueError): + M.ask("q1", " ", _cfg(), client=FakeQuill([]), llm_call=lambda m: "x") + + +def test_ask_disabled(): + out = M.ask("q1", "x", {"meetings": {"enabled": False}}, client=FakeQuill([]), llm_call=lambda m: "x") + assert out["ok"] is False + + +# ---------- snapshot ------------------------------------------------------------------ + +def test_config_snapshot_defaults(): + snap = M.config_snapshot(None) + assert snap["enabled"] is False + assert snap["mcp_url"].endswith("/mcp") + assert snap["batch"]["start"] == "17:00" + assert snap["batch"]["max_per_run"] == 10 diff --git a/tests/test_ffp_quill.py b/tests/test_ffp_quill.py new file mode 100644 index 0000000..c1fa19c --- /dev/null +++ b/tests/test_ffp_quill.py @@ -0,0 +1,49 @@ +"""Tests for ffp_quill parsing helpers (no network).""" + +from __future__ import annotations + +import ffp_quill + + +def test_parse_meetings(): + text = ( + '' + '' + 'Arseniy / Jeff 1:1' + '' + 'LPS WMS & Cycle' + '' + ) + out = ffp_quill._parse_meetings(text) + assert len(out) == 2 + assert out[0]["id"] == "abc" + assert out[0]["title"] == "Arseniy / Jeff 1:1" + assert out[0]["participants"] == "me, and Jeff" + assert out[0]["url"] == "quill://meeting/abc" + assert out[1]["title"] == "LPS WMS & Cycle" # entity unescaped + + +def test_parse_meetings_empty(): + assert ffp_quill._parse_meetings("") == [] + assert ffp_quill._parse_meetings("") == [] + + +def test_clean_text_strips_wrapper_and_entities(): + raw = 'AG:\n″Hello's″ world•' + cleaned = ffp_quill.clean_text(raw) + assert "" not in cleaned + assert '"Hello\'s" world' in cleaned # smart quotes + apostrophe normalized, bullet stripped + + +def test_parse_sse(): + body = ( + "event: message\n" + 'data: {"result":{"ok":true},"id":1}\n' + "\n" + "data: [DONE]\n" + ) + objs = ffp_quill._parse_sse(body) + assert objs == [{"result": {"ok": True}, "id": 1}]