Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions config/grammar_hotkey.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions scripts/ffp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions scripts/ffp_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate Quill searches on the opt-in flag

When meetings.enabled is false, opening the Meetings tab still calls searchMeetings(), which reaches this action and queries Quill with the configured/default URL. That violates the off-by-default opt-in behavior and can expose meeting titles/participants even though the integration is disabled; the Quill data actions should refuse unless meetings.enabled is true.

Useful? React with 👍 / 👎.



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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
Loading