From 2cd8afad960c28a7066d493c70e51111de2075e3 Mon Sep 17 00:00:00 2001 From: agrechenkov Date: Wed, 1 Jul 2026 10:10:58 -0400 Subject: [PATCH] =?UTF-8?q?Release=202.1.0=20=E2=80=94=20Quill=20meetings,?= =?UTF-8?q?=20notifications,=20quality=20digests,=20audit=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on origin/main after the local git store was lost to external agent activity (.git wiped); all working files were preserved and re-verified. 2.1.0 includes: - Quill meetings integration + after-hours digest scheduler (ffp_quill / ffp_meetings): search / ask / process, cached digests, action-item review board (accept/reject), weekly review, Overview meeting hours. - Digest quality flags + strict re-digest (meeting_redigest). - Notifications settings panel + telemetry feed. - Fixes: Run-now autosave, benchmark empty-row, meetings scroll + counter, active-hours telemetry, packaging parity (ffp_meetings/notifications/quill now in pyproject + PyInstaller spec, guarded by test), config-seed de-dup. - Removed dead ffp_tools.py; refreshed README/installer docs; added SPEC.md. Version 2.0.0 -> 2.1.0. Gates: ruff, 320 pytest, node --check, AHK parse-check. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + CHANGELOG.md | 22 ++ README.md | 18 +- SPEC.md | 106 +++++++ installer/README.md | 9 +- installer/fastflowprompt.spec | 8 +- installer/installer.iss | 2 +- installer/sign.ps1 | 2 +- pyproject.toml | 14 +- scripts/_version.py | 2 +- scripts/ffp_benchmark.py | 4 + scripts/ffp_daemon.py | 52 +++- scripts/ffp_meetings.py | 274 +++++++++++++++++- scripts/ffp_quill.py | 4 +- scripts/ffp_tools.py | 201 ------------- scripts/paths.py | 1 + scripts/ui/web/app.js | 216 +++++++++++++- scripts/ui/web/index.html | 49 +++- scripts/ui/web/styles.css | 27 ++ .../grammar_hotkey.config.example.json | 8 + sync.ps1 | 61 ++++ tests/test_config_seeds.py | 26 ++ tests/test_ffp_daemon.py | 12 +- tests/test_ffp_meetings.py | 137 ++++++++- tests/test_ffp_notifications.py | 2 +- tests/test_packaging_modules.py | 37 +++ 26 files changed, 1042 insertions(+), 255 deletions(-) create mode 100644 SPEC.md delete mode 100644 scripts/ffp_tools.py create mode 100644 sync.ps1 create mode 100644 tests/test_config_seeds.py create mode 100644 tests/test_packaging_modules.py diff --git a/.gitignore b/.gitignore index 1347a87..9a95cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ Thumbs.db ehthumbs.db .DS_Store desktop.ini + +# Serena agent artifacts +.serena/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7722c86..7c1f29e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,31 @@ ## Unreleased +## 2.1.0 + +**Meetings, on your own time.** Flowkey can now read your local Quill meetings and answer questions about them on the local model — and an after-hours scheduler pre-computes each meeting's digest (summary / goals / action items) during your idle window, so daytime reads are instant. + ### 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. +- **Action-item review board.** A weekly/monthly board on the Meetings tab aggregates the action items parsed from your meeting digests, each markable **accepted / rejected / pending** (status persisted in `data/meeting_action_status.jsonl`). New daemon actions `meeting_actions_list` / `meeting_action_set_status`. +- **Weekly review.** A one-click roll-up of the week's processed meetings (highlights / themes / open items) generated on the local model from the cached digests — pick the week and Generate. New daemon action `meeting_week_summary`. +- **Meeting hours on the Overview tab** — today / this-week meeting counts and hours, pulled from Quill (new daemon action `meeting_overview`). +- **Digest quality flags + strict re-digest.** Each meeting digest is checked for low-substance / social-filler / too-short / trivial-meeting signals and flagged in the Meetings tab; a "Re-digest (strict)" button re-runs the summary with a stricter prompt. New daemon action `meeting_redigest`. + +### Fixed + +- Benchmark history no longer shows a blank row for an interrupted/empty run. +- "Run batch now" persists the current Meetings settings (including the Enable toggle) before running, so it reflects the form rather than the last-saved config. +- The Meetings results list is now scrollable and shows a meeting counter. +- **Packaging now ships all runtime modules.** `ffp_meetings`, `ffp_notifications`, and `ffp_quill` were missing from `pyproject.toml` `py-modules` and the PyInstaller spec `hiddenimports`, so a wheel / frozen installer could omit them and crash on import even though source-tree tests passed. All three are now declared, and a new test (`test_packaging_modules`) asserts `py-modules` and the spec stay in sync with `scripts/*.py`. +- The Telemetry time-of-day chart now renders only **active hours** — zero-activity hours are dropped instead of drawn as empty bars (and an empty history shows "No activity yet"). + +### Internal + +- Removed the unused `ffp_tools.py` tool-calling prototype (no runtime caller). +- Unified the config seed templates: the dev example (`config/`) and the shipped first-run seed (`setup/defaults/`) had drifted (the seed was missing the `llm` block) — they're now identical, enforced by `test_config_seeds`. +- Docs refreshed for the current UI/build: README dashboard tabs (Chat + Meetings), the `open_chat` hotkey (`Ctrl+Alt+C`), a new "Supported surfaces" section (web chat only, installer-vs-source install, per-user autostart), and the installer README (3 exes, dropped retired `ffp-chat.exe` references). ## 2.0.0 diff --git a/README.md b/README.md index 717d4d8..d5b0d4d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ Flowkey is a Windows desktop assistant that adds local-LLM hotkeys for grammar f Everything runs locally through [FastFlowLM](https://fastflowlm.com) (AMD Ryzen AI NPU) or, on machines without the NPU, through [Ollama](https://ollama.com) (CPU/GPU) as a secondary provider. No cloud service, analytics, or telemetry is used by the app. -Current version: `2.0.0` +Current version: `2.1.0` + +## What's new in 2.1 + +- **Meetings, on your own time.** Connect the local [Quill](https://quillapp.com) note app to search your meetings and ask about them on the local model. Because asking about a full transcript costs real prefill time on the NPU, an **after-hours scheduler** pre-computes each meeting's digest (summary / goals / action items) during a configurable idle window (default 17:00–21:00) so daytime reads are instant. New **Meetings** tab + Config card; off by default. ## What's new in 2.0 @@ -98,7 +102,7 @@ Launch the app with AutoHotkey v2: | `explain:` + `Ctrl+Shift+G` | Explain code, regex, SQL, or technical text | | `tone:` + `Ctrl+Shift+G` | Rewrite in the selected tone preset | | `:` + `Ctrl+Shift+G` | Any mode you define in Dashboard → Config → Custom modes (e.g. `translate:`) | -| `Ctrl+Shift+T` | Open chat (each tab has a "My notes" toggle that grounds replies in your notes vault) | +| `Ctrl+Alt+C` | Open chat (the Chat tab has a "My notes" toggle that grounds replies in your notes vault) | | `Ctrl+Shift+A` | Ask in chat with selected text | | `Ctrl+Alt+N` | Capture a note | @@ -108,12 +112,20 @@ Prefix tip: put the keyword on the first line of your selection — `prompt: rou The dashboard is a web page served by the local daemon — open it from the tray menu ("Dashboard") or browse to `http://127.0.0.1:52650/`. It is loopback-only and works in any browser. -- **Tabs:** Overview, History, Telemetry, Notes, Benchmark, Config. +- **Tabs:** Overview, Chat, Telemetry, History, Notes, Meetings, Config. - **Theme:** auto-follows your OS day/night setting; the topbar button cycles auto → light → dark. - **Custom modes:** Config → Custom modes lets you add your own `prefix:` commands (id + system prompt). Changes apply to the running app within a second. - **Models:** pull models with live progress — pick a suggestion or type any name (on Ollama, anything from the [library](https://ollama.com/library) works); set active, remove. Suggestions are hardware-aware: detected RAM/VRAM caps the model size (e.g. 32 GB RAM → ~4B on the NPU; 8 GB VRAM → ~9B on the GPU), oversized models are hidden, and free-typing one asks before pulling. - **Benchmark:** works on both providers — `flm bench` on FastFlowLM (~10–20 min, NPU), timed generations with native metrics on Ollama (~1–3 min, server keeps running). - **Notes:** browse or search your vault; History shows recent runs (text is stored only if history storage is enabled). +- **Meetings:** connect the local [Quill](https://quillapp.com) app to search meetings, read AI digests (pre-computed after-hours), review action items (accept / reject), and generate a weekly review. Off by default — enable in Config → Meetings. +- **Notifications:** per-event toggles, dedupe window, Do-Not-Disturb, and quiet hours; every toast (shown or muted) is logged to the Telemetry feed. + +## Supported surfaces + +- **Chat is web-only.** Chat lives in the dashboard's **Chat** tab (`Ctrl+Alt+C` or tray → "Open Chat"). The old standalone modal chat popup is retired — there is no separate chat window. +- **Two install paths.** The signed **Inno Setup installer** (`Flowkey-Setup-.exe`, per-machine, admin) *or* a **source install** (`INSTALL.cmd`, runs from an unzipped folder — no build, no signing). Both are supported; pick one. +- **Autostart is per-user.** "Launch Flowkey when I sign in" is a single per-user `HKCU\…\Run` entry the daemon manages from Dashboard → Config — that toggle is the source of truth. The installer doesn't add machine-wide autostart. ## Project Layout diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..ab3dca9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,106 @@ +# Flowkey SPEC + +Caveman-encoded (compression, not amputation). Paths / ids / action names / numbers / endpoints verbatim. + +## §G goals + +- G1: local LLM hotkey assistant, Windows. ⊥ cloud, ⊥ analytics, ⊥ telemetry off-machine. +- G2: run on AMD NPU (FastFlowLM) | any CPU/GPU (Ollama). provider ? → auto-fallback to other. +- G3: web dashboard = single home for chat, notes, meetings, config, benchmark, notifications. +- G4: heavy LLM cost (prefill) → pre-compute after-hours, read cached. + +## §C context / stack + +- front: AHK v2 — `scripts/grammarFix.ahk` + `scripts/lib/*` + `scripts/ui/*` +- daemon: Python stdlib — `scripts/ffp_daemon.py` @ `http://127.0.0.1:52650` (single-instance = bound port) +- LLM: FastFlowLM NPU @ `:52625` | Ollama @ `:11434`, OpenAI-compat `POST /v1/chat/completions` +- dashboard: daemon-served `scripts/ui/web/{index.html,app.js,styles.css}`, CSP `default-src 'self'` +- paths: `scripts/paths.py` → USER_ROOT/{config,data,logs}; `_version.py` = version src of truth +- version: current `2.1.0` (held `release/v2.1.0`); public `2.0.0`; repo `agr77one/Fastflow` +- run tree = `flowkey-pub2` (worktree, branch `live`=origin/main). old `FastFlowPrompt_Local_Setup`=1.5.0 stale. + +## §I interfaces + +- cfg blocks: `enabled`, `llm`, `providers.{fastflowlm,ollama}`, `server`, `routing`, `notes`, `chat`, `modes`, `dictionary`, `notifications`, `meetings`, `hotkeys` +- api: `POST /action/` ! header `X-FFP-API: 1` → 200 `{ok,result,error,elapsed_ms}` +- api: `GET /` → dashboard; `GET /healthz` → `{ok,version,api,actions}` +- action: `config_snapshot` → full cfg; `apply_config_patch {patch}` → merge (whitelist `filter_config_patch`) +- action: `notify_gate {title,message}` → `{show,reason,category}` (logs); `notifications_log {limit}` → rows +- action: `quill_status` → `{reachable,enabled,server,server_version}` +- action: `quill_search_meetings {query,limit}` → `{meetings:[{id,title,date,duration,participants,url}]}` +- action: `meeting_overview` → `{enabled,reachable,today:{count,minutes},week:{count,minutes}}` +- action: `meeting_process {meeting_id,title,date,url}` → digest rec (writes `meeting_digests.jsonl`) +- action: `meeting_batch_run {max_per_run?}` → `{ok,processed,queued,errors}`; `meeting_batch_status` → status +- action: `meeting_digest_get {meeting_id}` → `{found,digest_md,...}`; `meeting_digests_list` → `{digests,count}` +- action: `meeting_ask {meeting_id,question}` → `{ok,answer,source,seconds}` +- action: `meeting_actions_list {range:week|month}` → `{range,items:[{id,text,owner,status,...}],counts}` +- action: `meeting_action_set_status {id,status:pending|accepted|rejected}` → `{ok}` +- action: `meeting_week_summary {week_offset}` → `{ok,week_label,meeting_count,summary}` +- mcp: Quill @ `http://127.0.0.1:19532/mcp` — Streamable-HTTP, SSE `data:`, `Mcp-Session-Id` header; init→notifications/initialized→tools/call +- cmd: `flm serve --pmode turbo --host 127.0.0.1 --port 52625` +- data: `data/{meeting_digests,meeting_action_status,notifications,chat_threads}.jsonl` +- autostart: HKCU Run `FastFlowPrompt` → bundled `AutoHotkey64.exe` + `grammarFix.ahk`; `FlowkeyGitSync` → `sync.ps1` +- sched: Windows task `FlowkeyGitSync` daily 12:00 → `sync.ps1` (ff-only pull, guarded) +- ACTIONS count = 73 + +## §V invariants + +- V1: ∀ `POST /action` → header `X-FFP-API` = API_VERSION `1` | 403 +- V2: ∀ req Host ∉ {`127.0.0.1`,`localhost`} → 403 (DNS-rebind defense) +- V3: config patch → only keys ∈ `filter_config_patch` whitelist; rest dropped +- V4: flm/llm/`meetings.mcp_url` ! loopback http/https | reject (SSRF guard) +- V5: dashboard DOM → createElement/textContent only; ⊥ innerHTML; ⊥ native alert/confirm/prompt → use `confirmDialog` +- V6: history text redacted by default (`history_store_text` false) +- V7: notes ops ! contained via `_vault_subpath`; `../` → reject +- V8: notify → daemon `notify_gate` decides+logs; AHK `Notify_Impl` fail-OPEN if daemon unreachable +- V9: notify category `errors` (critical) → bypass DND & quiet_hours; still logged; still honors per-cat disable +- V10: notify master `enabled`=false → mute all incl errors +- V11: scheduler run ⟺ `should_run_batch` = meetings.enabled & in-window & (idle ? idle≥threshold) +- V12: after-hours batch idempotent → skip meeting ∃ cached digest +- V13: `meeting_*` actions ∉ `_WRITE_ACTIONS` → self-lock (`_io_lock`/`_batch_lock`), separate files ∴ long batch ⊥ block config/notify writes +- V14: NPU prefill ∝ context (~17s @ ~7k tok) ∴ pre-compute digests after-hours; ask grounds on cached digest +- V15: action-item id = `sha1(meeting_id|norm(text))[:16]` → stable across re-list ∴ status persists +- V16: week = Monday 00:00 local; month = 1st 00:00 local +- V17: builtin mode prompts locked from patching (only `tone.preset` patchable) +- V18: version ∀ ∈ {`_version.py`,`pyproject.toml`,`installer/installer.iss`,`README.md`} equal; CI smoke fails on drift +- V19: `main` branch-protected (ruleset 17344133) → land via PR + ruleset toggle; ⊥ direct push +- V20: change gates ! pass: `ruff check scripts tests`, `pytest`, `node --check scripts/ui/web/app.js`, AHK parse-check (PowerShell `/ErrorStdOut`) +- V21: local data (config/data/logs/vendor/certs) ∈ `.gitignore` ∴ pull moves code only, never user data +- V22: `sync.ps1` ∃ uncommitted tracked changes → skip pull (⊥ clobber un-pushed WIP) + +## §T tasks + +``` +id|status|task|cites +T1|x|web dashboard = home (chat/notes/config/bench/notifications/meetings)|V5 +T2|x|Ollama provider + auto-fallback + hw-aware model sizing|G2 +T3|x|notifications gate+log+panel (`ffp_notifications`)|V8,V9,V10 +T4|x|Quill meetings + after-hours digest scheduler (`ffp_quill`,`ffp_meetings`)|V11,V12,V14 +T5|x|overview meeting hours + action-item board + weekly review|V15,V16 +T6|x|git autosync: `sync.ps1` + daily task + autostart→flowkey-pub2|V21,V22 +T7|~|2.1.0 release held on `release/v2.1.0` → land after user test|V18,V19 +T8|.|installer clean-VM smoke test|— +T9|.|[AUDIT] dead-code: unused daemon helper + 2 AHK wrappers + stale chat-popup config key + obsolete settings ref in test fixture + deprecated install shims|— +T10|.|[AUDIT-P1] autostart: unify 3 divergent Run keys (daemon/src-installer/pkg-installer) → single HKCU entry; fix UI autostart status reporting|V20 +T11|.|[AUDIT] old open_chat default `^+t` still appears in first-run + web config fallback → replace with `^!c`|B5 +T12|.|[AUDIT] first-run seed thinner than DEFAULT_CONFIG schema → add seed-vs-schema drift guard (compare keys on first-run copy)|— +T13|.|[AUDIT] installer bootstrap wrapper hardcodes old installer filename → derive from `_version.py`|V18 +T14|.|[AUDIT] quality-gate gaps: installer policy drift, autostart reg-name drift, bootstrap output name, README/dashboard tab count|V20 +T15|.|[DOCS] dashboard docs: 7 tabs listed, live = 8 (add Benchmark)|— +T16|.|[DOCS] autostart docs conflict: main says no machine-wide entry; installer docs+impl still describe it → align on HKCU-only|— +T17|.|[DOCS] installer layout: build script says flattened, installer.md still shows nested layout|— +T18|.|[DOCS] provider roadmap marks selector/status UX incomplete → update to reflect it exists|— +T19|.|[DOCS] first-run wizard text: "chat popup" + retired hotkey → update to current|B5 +T20|.|[DOCS] daemon log location stale in docs → update to current path|— +``` + +## §B bugs + +``` +id|date|cause|fix +B1|2026-06|"Run batch now" ran vs last-saved cfg not form → "disabled"|autosave meetings patch before `meeting_batch_run` +B2|2026-06|bench history blank row from 0-point result file|skip `rows==[]` in `ffp_benchmark.history` +B3|2026-06|autostart → stale tree / empty `flowkey-public`|repoint HKCU Run → flowkey-pub2 + bundled AHK +B4|2026-06|install launch: AHK called `.py`, shipped only `.exe`|flatten bundle to {app} + AHK→exe bridge (PR #19) +B5|2026-06|`Ctrl+Shift+T` open_chat collided w/ browser reopen-tab|default → `^!c`; tray label = configured hotkey +``` diff --git a/installer/README.md b/installer/README.md index e1b0db5..900cfdb 100644 --- a/installer/README.md +++ b/installer/README.md @@ -7,7 +7,7 @@ Everything needed to turn this project into a signed Windows installer that non- ``` installer/ ├── build.ps1 ← end-to-end orchestrator (run this) -├── fastflowprompt.spec ← PyInstaller spec (4 exes, onedir, MERGE-dedup) +├── fastflowprompt.spec ← PyInstaller spec (3 exes, onedir, MERGE-dedup) ├── installer.iss ← Inno Setup 6.x script (per-machine, admin) ├── sign.ps1 ← cert generation + signtool wrapper ├── certs/ ← .pfx and .cer (gitignored) @@ -66,12 +66,12 @@ SmartScreen on first launch. Steps the script runs: -1. Read `scripts\_version.py` → derive version (e.g. `2.0.0`) +1. Read `scripts\_version.py` → derive version (e.g. `2.1.0`) 2. Generate `file_version_info.txt` for the Win32 VERSIONINFO resource 3. Download `vendor\ahk\AutoHotkey64.exe` if missing (`-BundleAhk`) 4. Download `vendor\flm\flm-setup.exe` if missing (`-BundleFlm`) 5. Run `pyinstaller --clean --noconfirm fastflowprompt.spec` → `dist\FastFlowPrompt\` -6. Run `iscc installer.iss` → `out\Flowkey-Setup-2.0.0.exe` +6. Run `iscc installer.iss` → `out\Flowkey-Setup-2.1.0.exe` 7. Run `sign.ps1` against the output (`-Sign`) Debug flags: @@ -93,7 +93,7 @@ Debug flags: ## What the uninstaller does -1. Kills `ffp-daemon.exe`, `ffp-chat.exe`, and the AHK process running +1. Kills `ffp-daemon.exe` and the AHK process running `grammarFix.ahk` so file removal doesn't fail on in-use binaries. 2. Chain-uninstalls FastFlowLM via its `QuietUninstallString` — but only if we set the `.flm_installed_by_us` marker. Users who already had FLM keep @@ -128,7 +128,6 @@ C:\Program Files\FastFlowPrompt\ (read-only, admin-installed) ├── Flowkey\ PyInstaller bundle │ ├── ffp-daemon.exe │ ├── ffp-grammar-fix.exe -│ ├── ffp-chat.exe │ ├── ffp-first-run.exe │ ├── _internal\ │ └── setup\defaults\ diff --git a/installer/fastflowprompt.spec b/installer/fastflowprompt.spec index 209edb2..25209b9 100644 --- a/installer/fastflowprompt.spec +++ b/installer/fastflowprompt.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -"""PyInstaller spec for Flowkey v1.5.0. +"""PyInstaller spec for Flowkey (version resolved from scripts/_version.py). Build: @@ -53,20 +53,22 @@ HIDDEN_IMPORTS = [ "ffp_benchmark", "ffp_chat", "ffp_config", + "ffp_daemon", "ffp_flm_server", "ffp_hardware", "ffp_llm_client", + "ffp_meetings", + "ffp_notifications", "ffp_notify", "ffp_provider_runtime", "ffp_provider_status", "ffp_pull", + "ffp_quill", "ffp_telemetry", - "ffp_tools", "ffp_updater", "loopback_http", "paths", "grammar_fix", - "ffp_daemon", "first_run", "install", "notes", diff --git a/installer/installer.iss b/installer/installer.iss index a322824..d05d643 100644 --- a/installer/installer.iss +++ b/installer/installer.iss @@ -41,7 +41,7 @@ #define AppURL "https://github.com/agr77one/Fastflow" #define AppExeName "Flowkey.exe" ; symbolic — actual launchers below ; Keep in lockstep with scripts\_version.py. -#define AppVersion "2.0.0" +#define AppVersion "2.1.0" [Setup] AppId={{8A4F1E6C-9B3D-4E62-9F7A-FASTFLOW140}} diff --git a/installer/sign.ps1 b/installer/sign.ps1 index 5aa62b8..28ca67d 100644 --- a/installer/sign.ps1 +++ b/installer/sign.ps1 @@ -52,7 +52,7 @@ .EXAMPLE # Sign the installer $env:FFP_SIGN_PFX_PASSWORD = "ChangeMe!" - .\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.0.0.exe + .\sign.ps1 -FilePath ..\out\Flowkey-Setup-2.1.0.exe #> [CmdletBinding(DefaultParameterSetName = "Sign")] diff --git a/pyproject.toml b/pyproject.toml index 63d2c6a..0ab0c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ build-backend = "setuptools.build_meta" [project] name = "fastflowprompt" -version = "2.0.0" +version = "2.1.0" description = "Local-LLM-powered grammar fix, prompt rewrite, chat, and dashboard for Windows." readme = "README.md" requires-python = ">=3.11" @@ -79,23 +79,25 @@ py-modules = [ "ffp_benchmark", "ffp_chat", "ffp_config", + "ffp_daemon", "ffp_flm_server", "ffp_hardware", "ffp_llm_client", + "ffp_meetings", + "ffp_notifications", "ffp_notify", "ffp_provider_runtime", "ffp_provider_status", "ffp_pull", + "ffp_quill", "ffp_telemetry", - "ffp_tools", "ffp_updater", - "loopback_http", - "paths", - "grammar_fix", - "ffp_daemon", "first_run", + "grammar_fix", "install", + "loopback_http", "notes", + "paths", "subprocess_util", ] diff --git a/scripts/_version.py b/scripts/_version.py index 2faa2b7..acb9d44 100644 --- a/scripts/_version.py +++ b/scripts/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the app version. Read by grammar_fix.py.""" -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/scripts/ffp_benchmark.py b/scripts/ffp_benchmark.py index 4bcb512..493293a 100644 --- a/scripts/ffp_benchmark.py +++ b/scripts/ffp_benchmark.py @@ -329,6 +329,10 @@ def history(bench_root) -> dict: log.warning("skipping unreadable benchmark result (%s): %s", f, exc) continue rows = data.get("rows") or [] + if not rows: + # A run that was interrupted / errored before producing any data point + # leaves an empty result file; don't surface it as a blank history row. + continue decode_vals = [r.get("decode_tps") for r in rows if isinstance(r.get("decode_tps"), (int, float))] prefill_vals = [r.get("prefill_tps") for r in rows if isinstance(r.get("prefill_tps"), (int, float))] runs.append({ diff --git a/scripts/ffp_daemon.py b/scripts/ffp_daemon.py index 51699ab..27ba4c4 100644 --- a/scripts/ffp_daemon.py +++ b/scripts/ffp_daemon.py @@ -637,13 +637,21 @@ def _meetings_cfg() -> dict: 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)) + cfg = _meetings_cfg() + out = ffp_quill.status(str(cfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL)) + out["enabled"] = bool(cfg.get("enabled")) + return out + + +def _act_meeting_overview(_args: dict) -> dict: + import ffp_meetings + return ffp_meetings.meeting_overview(grammar_fix.load_config()) 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) + return ffp_quill.search_meetings(str(args.get("query") or ""), int(args.get("limit") or 30), int(args.get("offset") or 0), url=url) def _act_meeting_digest_get(args: dict) -> dict: @@ -669,6 +677,19 @@ def _act_meeting_process(args: dict) -> dict: return {"ok": True, **rec} +def _act_meeting_redigest(args: dict) -> dict: + """Re-run digest with the strict prompt, overwriting the cached result.""" + import ffp_meetings + mid = str(args.get("meeting_id") or "") + if not mid: + raise ValueError("meeting_redigest 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(), strict=True) + 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") @@ -687,6 +708,28 @@ def _act_meeting_ask(args: dict) -> dict: str(args.get("question") or ""), grammar_fix.load_config()) +def _act_meeting_actions_list(args: dict) -> dict: + """Action items from cached digests in the range (week|month), with status.""" + import ffp_meetings + return ffp_meetings.list_action_items(str(args.get("range") or "week")) + + +def _act_meeting_action_set_status(args: dict) -> dict: + """Set an action item's review status (pending|accepted|rejected).""" + import ffp_meetings + return ffp_meetings.set_action_status(str(args.get("id") or ""), str(args.get("status") or "")) + + +def _act_meeting_week_summary(args: dict) -> dict: + """Roll up a week's cached digests into one review (week_offset: 0=this week).""" + import ffp_meetings + try: + offset = int(args.get("week_offset") or 0) + except (TypeError, ValueError): + offset = 0 + return ffp_meetings.week_summary(grammar_fix.load_config(), week_offset=offset) + + ACTIONS: dict[str, Callable[[dict], Any]] = { "status": _act_status, "start": _act_start, @@ -747,12 +790,17 @@ def _act_meeting_ask(args: dict) -> dict: "chat_take_staged": _act_chat_take_staged, "quill_status": _act_quill_status, "quill_search_meetings": _act_quill_search_meetings, + "meeting_overview": _act_meeting_overview, "meeting_digest_get": _act_meeting_digest_get, "meeting_digests_list": _act_meeting_digests_list, "meeting_process": _act_meeting_process, + "meeting_redigest": _act_meeting_redigest, "meeting_batch_run": _act_meeting_batch_run, "meeting_batch_status": _act_meeting_batch_status, "meeting_ask": _act_meeting_ask, + "meeting_actions_list": _act_meeting_actions_list, + "meeting_action_set_status": _act_meeting_action_set_status, + "meeting_week_summary": _act_meeting_week_summary, "get_autostart_state": _act_get_autostart_state, "set_autostart": _act_set_autostart, "open_dashboard": _act_open_dashboard, diff --git a/scripts/ffp_meetings.py b/scripts/ffp_meetings.py index 6d8a915..5fe4a7b 100644 --- a/scripts/ffp_meetings.py +++ b/scripts/ffp_meetings.py @@ -21,7 +21,11 @@ from __future__ import annotations +import datetime +import hashlib +import json import logging +import re import threading import time @@ -31,6 +35,8 @@ log = logging.getLogger("ffp.meetings") DIGESTS_PATH = _paths.MEETING_DIGESTS_FILE +ACTION_STATUS_PATH = _paths.MEETING_ACTION_STATUS_FILE +VALID_ACTION_STATUSES = ("pending", "accepted", "rejected") DEFAULTS = { "enabled": False, @@ -228,19 +234,52 @@ def _fetch_content(meeting_id: str, cfg_meetings: dict, client) -> tuple[str, st "on, write '- (not discussed)'." ) +_STRICT_DIGEST_SYSTEM = ( + "You write concise, factual digests of meetings for later reference. " + "Use ONLY the provided content; never invent details. " + "STRICT RULES: " + "(1) Never include social pleasantries — greetings, thanks, farewells are NOT meeting content. " + "(2) Only bullet concrete decisions, problems raised, or information shared. " + "(3) If a section has no real content, write exactly '- None'. " + "(4) If the entire meeting was a trivial social exchange with no decisions, topics, or tasks, " + "write Summary as one bullet: '- Trivial exchange; no substantive content recorded.' " + "and Goals and Action items each as '- None'. " + "(5) Never pad empty sections with explanatory sentences." +) + +_SOCIAL_FILLER_PHRASES = ( + "thanked", "no further topics", "brief interaction", + "no topics were discussed", "signed off", "said goodbye", +) + -def _digest_prompt(title: str, date: str, content: str) -> list[dict]: +def _digest_prompt(title: str, date: str, content: str, *, strict: bool = False) -> list[dict]: + system = _STRICT_DIGEST_SYSTEM if strict else _DIGEST_SYSTEM 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" + "## Summary\n- 3-5 short bullets (decisions, topics, outcomes only — no pleasantries)\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}] + return [{"role": "system", "content": system}, {"role": "user", "content": user}] -def process_meeting(meeting: dict, cfg: dict, *, client=None, llm_call=None) -> dict: +def _digest_quality(digest_md: str, context_chars: int) -> dict: + text = (digest_md or "").lower() + flags: list[str] = [] + if text.count("not discussed") >= 2 or text.count("- none") >= 2: + flags.append("low_substance") + if any(p in text for p in _SOCIAL_FILLER_PHRASES): + flags.append("social_filler") + if context_chars < 400: + flags.append("trivial_meeting") + if len((digest_md or "").strip()) < 100: + flags.append("too_short") + return {"flags": flags, "ok": not bool(flags)} + + +def process_meeting(meeting: dict, cfg: dict, *, client=None, llm_call=None, strict: bool = False) -> 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 "") @@ -252,7 +291,7 @@ def process_meeting(meeting: dict, cfg: dict, *, client=None, llm_call=None) -> 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() + digest_md = str(call(_digest_prompt(meeting.get("title") or "", meeting.get("date") or "", content, strict=strict)) or "").strip() seconds = round(time.time() - t0, 2) provider, model = _provider_model() return { @@ -267,6 +306,8 @@ def process_meeting(meeting: dict, cfg: dict, *, client=None, llm_call=None) -> "context_chars": len(content), "seconds": seconds, "digest_md": digest_md, + "strict": strict, + "quality": _digest_quality(digest_md, len(content)), } @@ -365,6 +406,229 @@ def ask(meeting_id: str, question: str, cfg: dict, *, client=None, llm_call=None return {"ok": True, "answer": answer, "source": source, "seconds": round(time.time() - t0, 2)} +# ---------- meeting aggregation (Overview hours) -------------------------------------- + +_DUR_H_RE = re.compile(r"(\d+)\s*h") +_DUR_M_RE = re.compile(r"(\d+)\s*m") + + +def parse_duration_minutes(text) -> int: + """'31min' -> 31, '1h 5min' -> 65, '1h' -> 60. 0 if unparseable.""" + t = str(text or "").lower() + total = 0 + mh = _DUR_H_RE.search(t) + if mh: + total += int(mh.group(1)) * 60 + mm = _DUR_M_RE.search(t) + if mm: + total += int(mm.group(1)) + return total + + +def _parse_meeting_dt(value): + """ISO date (often UTC 'Z') -> local naive datetime, or None.""" + try: + dt = datetime.datetime.fromisoformat(str(value or "").replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.astimezone().replace(tzinfo=None) + return dt + except (ValueError, TypeError): + return None + + +def meeting_overview(cfg: dict, *, now=None, client=None) -> dict: + """Today / this-week meeting counts + minutes from Quill, for the Overview tab. + 'week' is since Monday 00:00 local. Returns reachable=False (fast) when the + integration is off or Quill is down.""" + mcfg = cfg.get("meetings") if isinstance(cfg.get("meetings"), dict) else {} + if not mcfg.get("enabled"): + return {"enabled": False, "reachable": False} + c = client or ffp_quill.QuillClient(str(mcfg.get("mcp_url") or ffp_quill.DEFAULT_MCP_URL)) + if not c.connect(): + return {"enabled": True, "reachable": False} + meetings = ffp_quill.list_recent_meetings(limit=30, client=c) + now = now or datetime.datetime.now() + today0 = now.replace(hour=0, minute=0, second=0, microsecond=0) + week0 = today0 - datetime.timedelta(days=today0.weekday()) # Monday 00:00 + agg = {"today": {"count": 0, "minutes": 0}, "week": {"count": 0, "minutes": 0}} + for m in meetings: + dt = _parse_meeting_dt(m.get("date")) + if dt is None: + continue + mins = parse_duration_minutes(m.get("duration")) + if dt >= week0: + agg["week"]["count"] += 1 + agg["week"]["minutes"] += mins + if dt >= today0: + agg["today"]["count"] += 1 + agg["today"]["minutes"] += mins + return {"enabled": True, "reachable": True, **agg} + + +# ---------- action items (review board) ----------------------------------------------- + +_ACTION_HEADER_RE = re.compile(r"##\s*action items?", re.IGNORECASE) +_BULLET_RE = re.compile(r"^[-*]\s+(.*)$") +_OWNER_RE = re.compile(r"^\[([^\]]*)\]\s*(.*)$") +_SKIP_ITEMS = {"(not discussed)", "(none)", "none", "n/a", "(not discussed.)"} + + +def extract_action_items(digest_md: str) -> list[dict]: + """Pull the bullets under the '## Action items' section of a digest. + Returns [{owner, text}]; skips placeholder bullets like '(not discussed)'.""" + items: list[dict] = [] + in_section = False + for raw in (digest_md or "").splitlines(): + s = raw.strip() + if s.startswith("##"): + in_section = bool(_ACTION_HEADER_RE.match(s)) + continue + if not in_section: + continue + mb = _BULLET_RE.match(s) + if not mb: + continue + body = mb.group(1).strip() + if not body or body.lower() in _SKIP_ITEMS or body.lower().startswith("(not discussed"): + continue + owner = "" + mo = _OWNER_RE.match(body) + if mo: + owner = mo.group(1).strip() + body = mo.group(2).strip() + if body: + items.append({"owner": owner, "text": body}) + return items + + +def _action_id(meeting_id: str, text: str) -> str: + norm = re.sub(r"\s+", " ", str(text or "").strip().lower()) + return hashlib.sha1(f"{meeting_id}|{norm}".encode()).hexdigest()[:16] + + +def _load_action_status() -> dict: + out: dict = {} + if not ACTION_STATUS_PATH.exists(): + return out + try: + with ACTION_STATUS_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 json.JSONDecodeError: + continue + iid = row.get("id") + if iid: + out[iid] = row + except OSError as exc: + log.warning("load action status failed: %s", exc) + return out + + +def set_action_status(item_id: str, status: str) -> dict: + """Persist a review status for one action item (pending/accepted/rejected).""" + item_id = str(item_id or "") + if not item_id: + raise ValueError("missing item id") + if status not in VALID_ACTION_STATUSES: + raise ValueError(f"invalid status: {status!r}") + with _io_lock: + statuses = _load_action_status() + statuses[item_id] = {"id": item_id, "status": status, "updated_at": _now_iso()} + try: + ACTION_STATUS_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = ACTION_STATUS_PATH.with_suffix(".jsonl.tmp") + with tmp.open("w", encoding="utf-8") as f: + for row in statuses.values(): + f.write(json.dumps(row, ensure_ascii=False) + "\n") + tmp.replace(ACTION_STATUS_PATH) + except OSError as exc: + log.warning("set action status failed: %s", exc) + return {"ok": True, "id": item_id, "status": status} + + +def _range_cutoff(range_key: str, now: datetime.datetime) -> datetime.datetime: + today0 = now.replace(hour=0, minute=0, second=0, microsecond=0) + if range_key == "month": + return today0.replace(day=1) + return today0 - datetime.timedelta(days=today0.weekday()) # this week (Monday) + + +def list_action_items(range_key: str = "week", *, now=None) -> dict: + """Aggregate action items from cached digests in the range (week|month), + each tagged with its persisted review status. Purely local — no Quill call.""" + range_key = "month" if str(range_key) == "month" else "week" + now = now or datetime.datetime.now() + cutoff = _range_cutoff(range_key, now) + statuses = _load_action_status() + items: list[dict] = [] + for d in load_digests(): + dt = _parse_meeting_dt(d.get("date")) + if dt is not None and dt < cutoff: + continue + mid = str(d.get("meeting_id") or "") + for it in extract_action_items(d.get("digest_md")): + iid = _action_id(mid, it["text"]) + items.append({ + "id": iid, + "text": it["text"], + "owner": it["owner"], + "meeting_id": mid, + "meeting_title": d.get("title") or "", + "date": d.get("date") or "", + "status": (statuses.get(iid) or {}).get("status", "pending"), + }) + items.sort(key=lambda x: str(x.get("date") or ""), reverse=True) + counts = {"pending": 0, "accepted": 0, "rejected": 0} + for it in items: + counts[it["status"]] = counts.get(it["status"], 0) + 1 + return {"range": range_key, "items": items, "counts": counts} + + +# ---------- weekly review summary ----------------------------------------------------- + +_WEEK_SYSTEM = ( + "You write a brief, concrete weekly review from a set of meeting digests. " + "Use only the provided content; do not invent." +) + + +def week_summary(cfg: dict = None, *, week_offset: int = 0, now=None, llm_call=None) -> dict: + """Roll up the week's cached digests into one review. week_offset=0 is the + current week, 1 is last week, etc. Local — grounds on cached digests.""" + now = now or datetime.datetime.now() + today0 = now.replace(hour=0, minute=0, second=0, microsecond=0) + monday = today0 - datetime.timedelta(days=today0.weekday()) - datetime.timedelta(weeks=int(week_offset or 0)) + week_end = monday + datetime.timedelta(days=7) + label = f"{monday.date().isoformat()} – {(week_end - datetime.timedelta(days=1)).date().isoformat()}" + + week_digests = [] + for d in load_digests(): + dt = _parse_meeting_dt(d.get("date")) + if dt is not None and monday <= dt < week_end: + week_digests.append(d) + if not week_digests: + return {"ok": True, "week_label": label, "meeting_count": 0, "summary": ""} + + blocks = [] + for d in sorted(week_digests, key=lambda x: str(x.get("date") or "")): + blocks.append(f"### {d.get('title') or 'Meeting'} ({str(d.get('date') or '')[:10]})\n{d.get('digest_md') or ''}") + context = "\n\n".join(blocks)[:24000] + user = ( + f"WEEK: {label}\nThis week's {len(week_digests)} meeting digest(s):\n\n{context}\n\n" + "Write a concise weekly review using EXACTLY these sections:\n" + "## Highlights\n- what got done / key decisions\n" + "## Themes\n- recurring topics across meetings\n" + "## Open items\n- follow-ups still needing attention\n" + ) + call = _resolve_llm_call(llm_call) + summary = str(call([{"role": "system", "content": _WEEK_SYSTEM}, {"role": "user", "content": user}]) or "").strip() + return {"ok": True, "week_label": label, "meeting_count": len(week_digests), "summary": summary} + + # ---------- dashboard snapshot -------------------------------------------------------- def config_snapshot(meetings_cfg) -> dict: diff --git a/scripts/ffp_quill.py b/scripts/ffp_quill.py index 2ab458d..dba6183 100644 --- a/scripts/ffp_quill.py +++ b/scripts/ffp_quill.py @@ -152,11 +152,13 @@ def status(url: str = DEFAULT_MCP_URL) -> dict: } -def search_meetings(query: str, limit: int = 10, *, url: str = DEFAULT_MCP_URL, client: QuillClient | None = None) -> dict: +def search_meetings(query: str, limit: int = 10, offset: int = 0, *, 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) + if offset: + args["offset"] = int(offset) text = c.call_tool("search_meetings", args) return {"meetings": _parse_meetings(text), "reachable": bool(c.session_id)} diff --git a/scripts/ffp_tools.py b/scripts/ffp_tools.py deleted file mode 100644 index 927d4e1..0000000 --- a/scripts/ffp_tools.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tool-calling for FastFlowLM (prototype). - -gemma4-it:e4b supports OpenAI-style tool calling. This module exposes a -`note_search` tool over the captured notes vault and a `chat_with_tools()` loop -that lets the model call it mid-conversation: - - 1. POST messages + tool schemas to FLM's /v1/chat/completions. - 2. If the reply contains tool_calls, run each locally, append the results as - role="tool" messages, and call again. - 3. Stop when the model returns a normal answer (no tool_calls) or after - MAX_ROUNDS; return the final text plus a trace of the tool calls made. - -Kept dependency-free (urllib) and side-effect-free at import so it can be unit -tested without a running server. See SPEC V37. -""" - -from __future__ import annotations - -import json -import logging -import urllib.request - -log = logging.getLogger("ffp.tools") - -DEFAULT_BASE_URL = "http://127.0.0.1:52625" -DEFAULT_MODEL = "gemma4-it:e4b" -MAX_ROUNDS = 4 - -NOTE_SEARCH_TOOL = { - "type": "function", - "function": { - "name": "note_search", - "description": ( - "Search the user's personal notes vault (captured snippets, saved " - "articles, ideas) and return the most relevant notes. Call this " - "whenever the user asks about something they may have saved, e.g. " - "'what did I note about X', 'find my notes on Y', 'did I save " - "anything about Z'." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Keywords to search for."}, - "limit": {"type": "integer", "description": "Max notes to return (default 5)."}, - }, - "required": ["query"], - }, - }, -} - -TOOLS = [NOTE_SEARCH_TOOL] - - -def _dispatch(name: str, arguments: dict) -> dict: - if name == "note_search": - import notes - query = str(arguments.get("query") or "").strip() - try: - limit = int(arguments.get("limit") or 5) - except (TypeError, ValueError): - limit = 5 - res = notes.search_notes(query, limit) - # Compact the result for the model: title + category + snippet only. - items = [ - {"title": r["title"], "category": r["category"], "snippet": r["snippet"]} - for r in res.get("results", []) - ] - return {"query": res.get("query"), "count": res.get("count", 0), "results": items} - return {"error": f"unknown tool: {name}"} - - -def run_tool_call(name: str, arguments_json) -> dict: - """Execute a tool by name. `arguments_json` may be a JSON string or dict.""" - if isinstance(arguments_json, str): - try: - args = json.loads(arguments_json) - except ValueError as exc: - log.debug("tool-call arguments were not valid JSON (%r): %s", arguments_json, exc) - args = {} - else: - args = arguments_json or {} - if not isinstance(args, dict): - args = {} - return _dispatch(name, args) - - -def _post_chat(base_url: str, model: str, messages: list, *, tools=None, timeout: int = 60) -> dict: - # NOTE: do NOT send "tool_choice" — FastFlowLM's gemma tool template rejects - # it with HTTP 500 ("type must be string, but is object"). The documented - # format is just a `tools` array of objects with object `parameters`. - body: dict = {"model": model, "messages": messages, "temperature": 0.2} - if tools: - body["tools"] = tools - req = urllib.request.Request( - base_url.rstrip("/") + "/v1/chat/completions", - data=json.dumps(body).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8", errors="replace")) - - -def chat_with_tools( - user_text: str, - *, - base_url: str = DEFAULT_BASE_URL, - model: str = DEFAULT_MODEL, - system_prompt: str | None = None, - timeout: int = 60, - tools=None, -) -> dict: - """Run one tool-enabled chat turn. Returns {text, tool_trace, rounds}.""" - sys_prompt = system_prompt or ( - "You are a helpful assistant with access to the user's personal notes " - "through the note_search tool. When the user asks about something they " - "might have saved, call note_search first, then answer using the notes " - "returned. Cite note titles you used." - ) - messages: list = [ - {"role": "system", "content": sys_prompt}, - {"role": "user", "content": user_text}, - ] - tool_schemas = tools if tools is not None else TOOLS - trace: list = [] - - for round_index in range(MAX_ROUNDS): - resp = _post_chat(base_url, model, messages, tools=tool_schemas, timeout=timeout) - # FastFlowLM 0.9.43 returns an in-band error (HTTP 200 body with - # {"error": {...,"code":500}}, "type must be string, but is object") - # whenever the model actually emits a tool call — its gemma tool-call - # serialization is broken. Fall back to a tool-free answer so the caller - # still gets a response instead of silent empty text. See SPEC V37. - if isinstance(resp, dict) and resp.get("error"): - fallback = _post_chat(base_url, model, messages, tools=None, timeout=timeout) - fb_msg = (fallback.get("choices") or [{}])[0].get("message") or {} - return {"text": (fb_msg.get("content") or "").strip(), "tool_trace": trace, - "rounds": round_index + 1, "tool_error": resp.get("error")} - message = (resp.get("choices") or [{}])[0].get("message") or {} - tool_calls = message.get("tool_calls") or [] - if not tool_calls: - return {"text": (message.get("content") or "").strip(), "tool_trace": trace, "rounds": round_index + 1} - messages.append(message) # the assistant turn that requested the tools - for call in tool_calls: - fn = call.get("function") or {} - name = fn.get("name") or "" - result = run_tool_call(name, fn.get("arguments")) - trace.append({"name": name, "arguments": fn.get("arguments"), "result_count": result.get("count")}) - messages.append({ - "role": "tool", - "tool_call_id": call.get("id") or name, - "name": name, - "content": json.dumps(result, ensure_ascii=False), - }) - - # Out of rounds: one more call without tools to force a text answer. - resp = _post_chat(base_url, model, messages, tools=None, timeout=timeout) - message = (resp.get("choices") or [{}])[0].get("message") or {} - return {"text": (message.get("content") or "").strip(), "tool_trace": trace, "rounds": MAX_ROUNDS} - - -def chat_with_notes_context( - user_text: str, - *, - base_url: str = DEFAULT_BASE_URL, - model: str = DEFAULT_MODEL, - limit: int = 5, - timeout: int = 120, -) -> dict: - """Note-augmented answer that does NOT rely on model-driven tool calling. - - FastFlowLM 0.9.43's gemma tool-call serialization 500s on every real tool - invocation (see chat_with_tools / SPEC V37), so this client-side path runs - note_search itself, injects the top matches as context, and asks the model - to answer from them. This is the working "ask about my notes" prototype. - - Returns {text, notes_used: [{title, category}], count}. - """ - result = run_tool_call("note_search", {"query": user_text, "limit": limit}) - hits = result.get("results", []) - if hits: - ctx_lines = [] - for i, h in enumerate(hits, 1): - ctx_lines.append(f"[{i}] {h['title']} (category: {h['category']})\n {h['snippet']}") - context = "\n".join(ctx_lines) - system = ( - "You answer using the user's personal notes provided below. Cite the " - "note titles you used. If the notes do not contain the answer, say so." - ) - user = f"Notes found for this question:\n{context}\n\nQuestion: {user_text}" - else: - system = "You are a helpful assistant." - user = f"(No matching notes were found.)\n\nQuestion: {user_text}" - messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] - resp = _post_chat(base_url, model, messages, tools=None, timeout=timeout) - message = (resp.get("choices") or [{}])[0].get("message") or {} - return { - "text": (message.get("content") or "").strip(), - "notes_used": [{"title": h["title"], "category": h["category"]} for h in hits], - "count": result.get("count", 0), - } diff --git a/scripts/paths.py b/scripts/paths.py index c4ba91e..6f71485 100644 --- a/scripts/paths.py +++ b/scripts/paths.py @@ -161,6 +161,7 @@ def ensure_dirs() -> None: 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" +MEETING_ACTION_STATUS_FILE: Path = DATA_DIR / "meeting_action_status.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 63d6cd2..bc93670 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -265,6 +265,20 @@ async function loadOverview() { } catch { /* totals stay at 0 when history is empty or the action fails */ } + try { + const mo = await action("meeting_overview"); + if (mo.reachable) { + setText("ov-mtg-today", (mo.today.minutes / 60).toFixed(1)); + setText("ov-mtg-week", (mo.week.minutes / 60).toFixed(1)); + $("ov-mtg-detail").textContent = `${mo.today.count} today · ${mo.week.count} this week`; + } else { + setText("ov-mtg-today", "–"); + setText("ov-mtg-week", "–"); + $("ov-mtg-detail").textContent = mo.enabled ? "Quill not reachable" : "Quill integration off (enable in Config)"; + } + } catch { + $("ov-mtg-detail").textContent = ""; + } } // ---- Telemetry ------------------------------------------------------------- @@ -332,17 +346,28 @@ function renderHours(buckets) { const axis = $("hours-axis"); chart.replaceChildren(); axis.replaceChildren(); - const max = Math.max(1, ...buckets); - buckets.forEach((count, hour) => { + // Render only hours that actually had activity — zero-activity hours are + // dropped instead of shown as empty bars (cleaner for sparse usage). + const active = []; + buckets.forEach((count, hour) => { if (count > 0) active.push([hour, count]); }); + if (!active.length) { + const note = document.createElement("span"); + note.className = "muted small"; + note.textContent = "No activity yet."; + chart.append(note); + return; + } + const max = Math.max(1, ...active.map(([, count]) => count)); + for (const [hour, count] of active) { const bar = document.createElement("div"); - bar.className = count > 0 ? "bar" : "bar empty"; + bar.className = "bar"; bar.style.height = `${Math.max(2, Math.round((count / max) * 100))}%`; bar.title = `${String(hour).padStart(2, "0")}:00 — ${count}`; chart.append(bar); const tick = document.createElement("span"); - tick.textContent = hour % 3 === 0 ? String(hour).padStart(2, "0") : ""; + tick.textContent = String(hour).padStart(2, "0"); axis.append(tick); - }); + } } // ---- History --------------------------------------------------------------- @@ -1167,15 +1192,24 @@ async function sendChat() { // actions; the daemon talks MCP to the local Quill app. CSP-safe DOM only. let currentMeeting = null; let digestIds = new Set(); +let mtgOffset = 0; +let renderedMeetingIds = new Set(); +const MTG_PAGE = 30; 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"; + if (!s.enabled) { + st.textContent = "Quill integration is off — enable it in Config › Meetings."; + st.className = "muted small bad"; + } else if (s.reachable) { + st.textContent = `Quill ${s.server_version || ""} connected`.trim(); + st.className = "muted small ok"; + } else { + st.textContent = "Quill not reachable — make sure Quill is running."; + st.className = "muted small bad"; + } } catch (e) { st.textContent = `status unavailable: ${e.message}`; st.className = "muted small bad"; @@ -1187,17 +1221,32 @@ async function loadMeetings() { digestIds = new Set(); } searchMeetings(); + loadActionItems(); } async function searchMeetings() { + mtgOffset = 0; + renderedMeetingIds = new Set(); + $("mtg-results").replaceChildren(); + $("mtg-header-count").textContent = ""; + $("mtg-count").textContent = ""; + $("mtg-load-more").hidden = true; + await _fetchMeetingsPage(); +} + +async function loadMoreMeetings() { + await _fetchMeetingsPage(); +} + +async function _fetchMeetingsPage() { 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 r = await action("quill_search_meetings", { query: q, limit: MTG_PAGE, offset: mtgOffset }); const meetings = r.meetings || []; - $("mtg-empty").hidden = meetings.length > 0; for (const m of meetings) { + if (!m.id || renderedMeetingIds.has(m.id)) continue; + renderedMeetingIds.add(m.id); const tr = document.createElement("tr"); tr.className = "mtg-row"; tr.style.cursor = "pointer"; @@ -1213,6 +1262,14 @@ async function searchMeetings() { } body.append(tr); } + const total = renderedMeetingIds.size; + $("mtg-empty").hidden = total > 0; + const hasMore = meetings.length === MTG_PAGE; + const countLabel = total ? `${total}${hasMore ? "+" : ""} meeting${total === 1 ? "" : "s"}` : ""; + $("mtg-count").textContent = countLabel; + $("mtg-header-count").textContent = countLabel ? `(${countLabel})` : ""; + $("mtg-load-more").hidden = !hasMore; + if (hasMore) mtgOffset += MTG_PAGE; } catch (e) { $("mtg-empty").hidden = true; const tr = document.createElement("tr"); @@ -1237,17 +1294,34 @@ function openMeeting(row) { loadDigest(); } +const _QUALITY_LABELS = { + low_substance: "⚠ low substance", + social_filler: "⚠ social filler", + trivial_meeting: "⚠ short meeting", + too_short: "⚠ digest too short", +}; + +function _showQuality(quality) { + const el = $("mtg-quality"); + if (!quality || quality.ok || !quality.flags?.length) { el.hidden = true; return; } + el.textContent = quality.flags.map((f) => _QUALITY_LABELS[f] || f).join(" · "); + el.hidden = false; +} + async function loadDigest() { const body = $("mtg-digest"); body.textContent = "Loading…"; $("mtg-process-status").textContent = ""; + $("mtg-quality").hidden = true; 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`; + const strictLabel = d.strict ? " · strict" : ""; + $("mtg-process-status").textContent = `cached ${(d.processed_at || "").replace("T", " ")} · ${d.source} · ${d.seconds}s${strictLabel}`; + _showQuality(d.quality); } 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."; + 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}`; @@ -1257,12 +1331,31 @@ async function loadDigest() { async function processMeetingNow() { if (!currentMeeting) return; $("mtg-process-status").textContent = "Processing on the local model… (first token can take ~15s on a full transcript)"; + $("mtg-quality").hidden = true; 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`; + _showQuality(r.quality); + digestIds.add(currentMeeting.id); + } catch (e) { + $("mtg-process-status").textContent = `⚠ ${e.message}`; + } +} + +async function redigestMeeting() { + if (!currentMeeting) return; + $("mtg-process-status").textContent = "Re-digesting with strict prompt… (can take ~15s)"; + $("mtg-quality").hidden = true; + try { + const r = await action("meeting_redigest", { + meeting_id: currentMeeting.id, title: currentMeeting.title, date: currentMeeting.date, url: currentMeeting.url, + }); + $("mtg-digest").textContent = r.digest_md || "(empty)"; + $("mtg-process-status").textContent = `strict · ${r.source} · ${r.seconds}s`; + _showQuality(r.quality); digestIds.add(currentMeeting.id); } catch (e) { $("mtg-process-status").textContent = `⚠ ${e.message}`; @@ -1326,6 +1419,15 @@ function meetingsPatch() { async function runBatchNow() { const s = $("mtg-run-status"); + // Persist the current settings first (incl. the Enable toggle) so "Run now" + // reflects what's on screen — otherwise it runs against the last-saved config. + s.textContent = "Saving settings…"; + try { + await action("apply_config_patch", { patch: { meetings: meetingsPatch() } }); + } catch (e) { + s.textContent = `⚠ couldn't save settings: ${e.message}`; + return; + } s.textContent = "Running… (this processes on the local model; may take a while)"; try { const r = await action("meeting_batch_run", {}); @@ -1337,6 +1439,88 @@ async function runBatchNow() { } } +// Action-items review board (week/month) — sourced from cached digests; status +// is persisted server-side. Purely local (no Quill call needed). +async function loadActionItems() { + const range = (document.querySelector('input[name="mtg-range"]:checked') || {}).value || "week"; + try { + renderActionItems(await action("meeting_actions_list", { range })); + } catch (e) { + $("mtg-actions-list").replaceChildren(); + $("mtg-actions-empty").hidden = true; + $("mtg-actions-counts").textContent = `(unavailable: ${e.message})`; + } +} + +function renderActionItems(data) { + const box = $("mtg-actions-list"); + box.replaceChildren(); + const items = data.items || []; + $("mtg-actions-empty").hidden = items.length > 0; + const c = data.counts || {}; + $("mtg-actions-counts").textContent = items.length + ? `(${c.pending || 0} pending · ${c.accepted || 0} accepted · ${c.rejected || 0} rejected)` + : ""; + for (const it of items) { + const row = document.createElement("div"); + row.className = `action-row status-${it.status}`; + const main = document.createElement("div"); + main.className = "action-main"; + const txt = document.createElement("div"); + txt.className = "action-text"; + txt.textContent = (it.owner ? `[${it.owner}] ` : "") + it.text; + const meta = document.createElement("div"); + meta.className = "muted small"; + meta.textContent = `${it.meeting_title || "meeting"} · ${String(it.date || "").slice(0, 10)}`; + main.append(txt, meta); + const btns = document.createElement("div"); + btns.className = "action-btns"; + const badge = document.createElement("span"); + badge.className = "action-badge"; + badge.textContent = it.status; + const mk = (label, status, title) => { + const b = document.createElement("button"); + b.className = "btn"; + b.textContent = label; + b.title = title; + b.addEventListener("click", () => setActionStatus(it.id, status)); + return b; + }; + btns.append(badge, mk("✓", "accepted", "Accept"), mk("✗", "rejected", "Reject"), mk("↺", "pending", "Mark pending")); + row.append(main, btns); + box.append(row); + } +} + +async function setActionStatus(id, status) { + try { + await action("meeting_action_set_status", { id, status }); + } catch { + /* a reload reflects the true state */ + } + loadActionItems(); +} + +async function generateWeekSummary() { + const offset = Number($("mtg-week-sel").value) || 0; + const st = $("mtg-week-status"); + const out = $("mtg-week-output"); + st.textContent = "Generating on the local model…"; + out.textContent = ""; + try { + const r = await action("meeting_week_summary", { week_offset: offset }); + if (r.meeting_count === 0) { + out.textContent = "No processed meetings in that week."; + st.textContent = r.week_label || ""; + } else { + out.textContent = r.summary || "(empty)"; + st.textContent = `${r.week_label} · ${r.meeting_count} meeting${r.meeting_count === 1 ? "" : "s"}`; + } + } catch (e) { + st.textContent = `⚠ ${e.message}`; + } +} + // ---- Tabs & refresh -------------------------------------------------------- const TAB_LOADERS = { @@ -1399,6 +1583,8 @@ document.addEventListener("DOMContentLoaded", () => { $("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-load-more").addEventListener("click", loadMoreMeetings); + $("mtg-redigest").addEventListener("click", redigestMeeting); $("mtg-results").addEventListener("click", (e) => { const row = e.target.closest(".mtg-row"); if (row) openMeeting(row); @@ -1408,6 +1594,8 @@ document.addEventListener("DOMContentLoaded", () => { $("mtg-ask-btn").addEventListener("click", askMeeting); $("mtg-ask-input").addEventListener("keydown", (e) => { if (e.key === "Enter") askMeeting(); }); $("mtg-run-now").addEventListener("click", runBatchNow); + document.querySelectorAll('input[name="mtg-range"]').forEach((r) => r.addEventListener("change", loadActionItems)); + $("mtg-week-gen").addEventListener("click", generateWeekSummary); $("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 c887bb4..26b0372 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -73,6 +73,14 @@

Hotkeys

Ask
+
+

Meetings (from Quill)

+
+
Today (hrs)
+
This week (hrs)
+
+

+
@@ -197,15 +205,19 @@

LLM behavior

-

Meetings checking Quill…

+

Meetings checking Quill…

- - - -
TitleDateParticipantsDigest
+
+ + + +
TitleDateParticipantsDigest
+
+

+

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

@@ -217,8 +229,10 @@

Meeting

+
+

         

Ask about this meeting

@@ -228,6 +242,31 @@

Ask about this meeting

+
+

Action items

+
+ + +
+
+ +
+
+

Weekly review

+

A roll-up of the week's processed meetings — highlights, themes, and open items — generated on the local model.

+
+ + + + +
+

+      
diff --git a/scripts/ui/web/styles.css b/scripts/ui/web/styles.css index 5c1ef3b..899d336 100644 --- a/scripts/ui/web/styles.css +++ b/scripts/ui/web/styles.css @@ -456,6 +456,33 @@ input[type="number"] { max-width: 120px; } } .check-row input { width: auto; margin-right: 8px; accent-color: var(--accent); } +/* Scrollable list container (meetings results, etc.) */ +.table-scroll { max-height: 520px; overflow-y: auto; } + +/* Action-item review rows */ +.action-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + padding: 7px 2px; + border-bottom: 1px solid rgba(128, 128, 128, 0.18); +} +.action-main { flex: 1; min-width: 0; } +.action-btns { display: flex; gap: 4px; flex-shrink: 0; align-items: center; } +.action-btns .btn { padding: 2px 8px; } +.action-badge { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.65; + align-self: center; + min-width: 58px; + text-align: right; +} +.action-row.status-accepted .action-text { text-decoration: line-through; opacity: 0.55; } +.action-row.status-rejected .action-text { text-decoration: line-through; opacity: 0.35; } + .radio-row { display: flex; gap: 18px; margin: 8px 0 14px; } .radio-row label { cursor: pointer; } .radio-row input { width: auto; margin-right: 6px; accent-color: var(--accent); } diff --git a/setup/defaults/grammar_hotkey.config.example.json b/setup/defaults/grammar_hotkey.config.example.json index 398155c..42c56d9 100644 --- a/setup/defaults/grammar_hotkey.config.example.json +++ b/setup/defaults/grammar_hotkey.config.example.json @@ -1,5 +1,13 @@ { "enabled": true, + "llm": { + "provider": "fastflowlm", + "base_url": "http://127.0.0.1:52625", + "model": "qwen3.5:4b", + "auth_bearer": "flm", + "timeout_seconds": 30, + "auto_start": true + }, "flm_base_url": "http://127.0.0.1:52625", "flm_model": "qwen3.5:4b", "flm_timeout_seconds": 30, diff --git a/sync.ps1 b/sync.ps1 new file mode 100644 index 0000000..10a3b4c --- /dev/null +++ b/sync.ps1 @@ -0,0 +1,61 @@ +<# + Flowkey git sync — fetch + fast-forward-pull the current branch so this folder + stays current with GitHub (agr77one/Fastflow). + + Safe by design: + - Local data is already .gitignore'd (config/, data/, logs/, vendor/, certs), + so a pull only ever moves CODE, never your config / notes / history. + - Guarded: if there are uncommitted *tracked* changes (un-pushed work in + progress), it SKIPS the pull and leaves your code exactly as-is. + - --ff-only: never auto-merges or rewrites history. + + Push stays manual via the PR flow (main is branch-protected) — this only pulls. + + Usage: + .\sync.ps1 # verbose, run by hand + .\sync.ps1 -Quiet # used by the FlowkeyGitSync scheduled task +#> +param([switch]$Quiet) + +$ErrorActionPreference = 'Continue' +$repo = $PSScriptRoot +$logFile = Join-Path $repo 'logs\git-sync.log' + +function Write-Log { + param($Message) + $line = '{0} {1}' -f (Get-Date -Format 's'), $Message + if (-not $Quiet) { Write-Host $line } + try { + $dir = Split-Path $logFile + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + Add-Content -Path $logFile -Value $line + } catch {} +} + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Log 'git not found on PATH; aborting.'; exit 1 } +Set-Location $repo + +$branch = (git rev-parse --abbrev-ref HEAD 2>$null) +if (-not $branch) { Write-Log 'not a git repository; aborting.'; exit 1 } +$branch = $branch.Trim() + +# Guard: don't pull over uncommitted TRACKED changes (protects un-pushed WIP). +$dirty = git status --porcelain --untracked-files=no +if ($dirty) { + Write-Log ("skip pull on '{0}': uncommitted changes present — your code stays as-is." -f $branch) + exit 0 +} + +git fetch origin --prune 2>&1 | ForEach-Object { Write-Log $_ } + +$upstream = git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>$null +if ($upstream) { + $before = (git rev-parse HEAD).Trim() + git pull --ff-only 2>&1 | ForEach-Object { Write-Log $_ } + $after = (git rev-parse HEAD).Trim() + if ($before -ne $after) { Write-Log ("pulled '{0}' -> {1}" -f $branch, $after.Substring(0, 7)) } + else { Write-Log ("'{0}' already up to date." -f $branch) } +} else { + $om = (git rev-parse --short origin/main 2>$null) + Write-Log ("'{0}' has no upstream; fetched origin (origin/main @ {1})." -f $branch, $om) +} diff --git a/tests/test_config_seeds.py b/tests/test_config_seeds.py new file mode 100644 index 0000000..ab01cfc --- /dev/null +++ b/tests/test_config_seeds.py @@ -0,0 +1,26 @@ +"""Drift guard: the dev config example and the shipped first-run seed must be +the same template. They live in two places (config/ for the repo, setup/defaults/ +for the installer + first-run copy); this keeps them from diverging. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEV_EXAMPLE = ROOT / "config" / "grammar_hotkey.config.example.json" +SHIPPED_SEED = ROOT / "setup" / "defaults" / "grammar_hotkey.config.example.json" + + +def test_seed_templates_are_identical(): + dev = json.loads(DEV_EXAMPLE.read_text(encoding="utf-8")) + shipped = json.loads(SHIPPED_SEED.read_text(encoding="utf-8")) + assert dev == shipped, "config/ example and setup/defaults/ seed have drifted" + + +def test_seed_defaults_safe(): + # Privacy/perf defaults must stay conservative in the shipped seed. + shipped = json.loads(SHIPPED_SEED.read_text(encoding="utf-8")) + assert shipped.get("history_store_text") is False + assert (shipped.get("server") or {}).get("performance_mode") == "balanced" diff --git a/tests/test_ffp_daemon.py b/tests/test_ffp_daemon.py index 4da733e..5882415 100644 --- a/tests/test_ffp_daemon.py +++ b/tests/test_ffp_daemon.py @@ -73,22 +73,26 @@ def test_actions_count_and_expected_names(daemon_module): # 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 + # meeting_ask -> 69; meeting_overview -> 70; meeting_actions_list / + # meeting_action_set_status / meeting_week_summary -> 73; meeting_redigest + # (strict re-run of a digest) -> 74. + assert len(daemon_module.ACTIONS) == 74 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", "quill_status", "quill_search_meetings", "meeting_digest_get", "meeting_digests_list", "meeting_process", "meeting_batch_run", - "meeting_batch_status", "meeting_ask"): + "meeting_batch_status", "meeting_ask", "meeting_overview", + "meeting_actions_list", "meeting_action_set_status", "meeting_week_summary", + "meeting_redigest"): 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"): + for a in ("meeting_process", "meeting_batch_run", "meeting_ask", "meeting_action_set_status"): 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"): diff --git a/tests/test_ffp_meetings.py b/tests/test_ffp_meetings.py index e2d51ba..ac67a75 100644 --- a/tests/test_ffp_meetings.py +++ b/tests/test_ffp_meetings.py @@ -13,6 +13,7 @@ @pytest.fixture(autouse=True) def _tmp_digests(tmp_path, monkeypatch): monkeypatch.setattr(M, "DIGESTS_PATH", tmp_path / "meeting_digests.jsonl") + monkeypatch.setattr(M, "ACTION_STATUS_PATH", tmp_path / "meeting_action_status.jsonl") class FakeQuill: @@ -35,8 +36,8 @@ def call_tool(self, name, arguments): if offset: # single page of results, like a small vault return "" rows = "".join( - f'' - f'{m["title"]}' + f'{m["title"]}' for m in self._meetings ) return f"{rows}" @@ -197,3 +198,135 @@ def test_config_snapshot_defaults(): assert snap["mcp_url"].endswith("/mcp") assert snap["batch"]["start"] == "17:00" assert snap["batch"]["max_per_run"] == 10 + + +# ---------- meeting hours / overview -------------------------------------------------- + +def test_parse_duration_minutes(): + assert M.parse_duration_minutes("31min") == 31 + assert M.parse_duration_minutes("1h 5min") == 65 + assert M.parse_duration_minutes("1h") == 60 + assert M.parse_duration_minutes("8min") == 8 + assert M.parse_duration_minutes("") == 0 + assert M.parse_duration_minutes(None) == 0 + + +def test_meeting_overview_buckets(): + # Naive dates (no 'Z') keep this timezone-independent on CI runners. + now = datetime.datetime(2026, 6, 18, 15, 0) # Thursday; Monday = 2026-06-15 + meetings = [ + {"id": "a", "title": "t1", "date": "2026-06-18T09:00:00", "duration": "30min"}, + {"id": "b", "title": "t2", "date": "2026-06-18T13:00:00", "duration": "1h"}, + {"id": "c", "title": "mon", "date": "2026-06-15T10:00:00", "duration": "45min"}, + {"id": "d", "title": "lastweek", "date": "2026-06-10T10:00:00", "duration": "60min"}, + ] + out = M.meeting_overview(_cfg(), now=now, client=FakeQuill(meetings)) + assert out["reachable"] is True + assert out["today"] == {"count": 2, "minutes": 90} + assert out["week"] == {"count": 3, "minutes": 135} # excludes last week + + +def test_meeting_overview_disabled(): + assert M.meeting_overview({"meetings": {"enabled": False}}) == {"enabled": False, "reachable": False} + + +# ---------- action items board -------------------------------------------------------- + +DIGEST_MD = ( + "## Summary\n- discussed staffing\n" + "## Goals\n- finalize roles\n" + "## Action items\n" + "- [Jeff] meet with Alan next week\n" + "- [unassigned] post the job opening\n" + "- (not discussed)\n" +) + + +def test_extract_action_items(): + items = M.extract_action_items(DIGEST_MD) + assert items == [ + {"owner": "Jeff", "text": "meet with Alan next week"}, + {"owner": "unassigned", "text": "post the job opening"}, + ] # placeholder bullet skipped; Summary/Goals bullets not included + + +def test_extract_action_items_handles_no_section(): + assert M.extract_action_items("## Summary\n- x\n## Goals\n- y") == [] + + +def test_action_items_list_and_status_roundtrip(): + now = datetime.datetime(2026, 6, 18, 12, 0) # Thursday + M.save_digest({"meeting_id": "m1", "title": "Staffing", "date": "2026-06-17T10:00:00", + "processed_at": "2026-06-17T18:00:00", "digest_md": DIGEST_MD}) + out = M.list_action_items("week", now=now) + assert len(out["items"]) == 2 + assert out["counts"] == {"pending": 2, "accepted": 0, "rejected": 0} + assert all(it["status"] == "pending" for it in out["items"]) + + target = out["items"][0]["id"] + M.set_action_status(target, "accepted") + out2 = M.list_action_items("week", now=now) + statuses = {it["id"]: it["status"] for it in out2["items"]} + assert statuses[target] == "accepted" + assert out2["counts"]["accepted"] == 1 and out2["counts"]["pending"] == 1 + + +def test_action_items_range_filter(): + now = datetime.datetime(2026, 6, 18, 12, 0) # week starts Mon 2026-06-15 + M.save_digest({"meeting_id": "recent", "title": "R", "date": "2026-06-16T10:00:00", + "processed_at": "2026-06-16T18:00:00", "digest_md": DIGEST_MD}) + M.save_digest({"meeting_id": "old", "title": "O", "date": "2026-05-20T10:00:00", + "processed_at": "2026-05-20T18:00:00", "digest_md": DIGEST_MD}) + week = M.list_action_items("week", now=now) + assert {it["meeting_id"] for it in week["items"]} == {"recent"} + month = M.list_action_items("month", now=now) # since 2026-06-01 + assert {it["meeting_id"] for it in month["items"]} == {"recent"} # 'old' is May + + +def test_set_action_status_rejects_bad_value(): + with pytest.raises(ValueError): + M.set_action_status("abc", "maybe") + + +# ---------- weekly review summary ----------------------------------------------------- + +def test_week_summary_rolls_up_digests(): + now = datetime.datetime(2026, 6, 18, 12, 0) # week Mon 2026-06-15 .. Sun 06-21 + M.save_digest({"meeting_id": "w1", "title": "Mon sync", "date": "2026-06-15T10:00:00", + "processed_at": "2026-06-15T18:00:00", "digest_md": "## Summary\n- shipped X"}) + M.save_digest({"meeting_id": "w2", "title": "Wed 1:1", "date": "2026-06-17T10:00:00", + "processed_at": "2026-06-17T18:00:00", "digest_md": "## Summary\n- planned Y"}) + M.save_digest({"meeting_id": "old", "title": "Old", "date": "2026-06-01T10:00:00", + "processed_at": "2026-06-01T18:00:00", "digest_md": "## Summary\n- ancient"}) + seen = {} + def fake_llm(messages): + seen["ctx"] = messages[-1]["content"] + return "## Highlights\n- shipped X and planned Y" + out = M.week_summary({}, week_offset=0, now=now, llm_call=fake_llm) + assert out["meeting_count"] == 2 # only this week's two + assert "shipped X" in seen["ctx"] and "planned Y" in seen["ctx"] + assert "ancient" not in seen["ctx"] # last-period digest excluded + assert out["summary"].startswith("## Highlights") + + +def test_week_summary_empty_week(): + now = datetime.datetime(2026, 6, 18, 12, 0) + out = M.week_summary({}, week_offset=2, now=now, llm_call=lambda m: "should not be called") + assert out["meeting_count"] == 0 and out["summary"] == "" + + +# ---------- digest quality flags (strict/quality feature) ----------------------------- + +def test_digest_quality_flags(): + q_short = M._digest_quality("tiny", 100) + assert "too_short" in q_short["flags"] + assert "trivial_meeting" in q_short["flags"] + assert q_short["ok"] is False + + long_text = "## Summary\n" + ("- a solid substantive point about the project and next steps\n" * 4) + q_ok = M._digest_quality(long_text, 5000) + assert "too_short" not in q_ok["flags"] + assert "trivial_meeting" not in q_ok["flags"] + + q_low = M._digest_quality("- not discussed\n- not discussed\n" + long_text, 5000) + assert "low_substance" in q_low["flags"] and q_low["ok"] is False diff --git a/tests/test_ffp_notifications.py b/tests/test_ffp_notifications.py index 90cbb0e..632e2af 100644 --- a/tests/test_ffp_notifications.py +++ b/tests/test_ffp_notifications.py @@ -62,7 +62,7 @@ def _notif(**overrides) -> dict: ("📋 Code snippet detected", "Paste, prefix with `explain:` to explain what it does.", "clipboard_suggestions"), # updates ("Flowkey", "Checking for updates…", "updates"), - ("Flowkey", "You're up to date (2.0.0).", "updates"), + ("Flowkey", "You're up to date (2.1.0).", "updates"), ("Flowkey", "Downloading update…", "updates"), ("Flowkey", "Update applied. Please restart grammarFix.ahk.", "updates"), # diagnostics diff --git a/tests/test_packaging_modules.py b/tests/test_packaging_modules.py new file mode 100644 index 0000000..0a0d0a0 --- /dev/null +++ b/tests/test_packaging_modules.py @@ -0,0 +1,37 @@ +"""Drift guard: every top-level scripts/*.py module must be declared for +packaging, in BOTH pyproject.toml (wheel / `pip install .`) and the PyInstaller +spec hiddenimports (frozen installer). + +Why: source-tree tests import from scripts/ directly, so a module that the +daemon imports (e.g. ffp_meetings / ffp_notifications / ffp_quill) can pass all +tests yet be omitted from the wheel/freeze and crash at runtime there. This test +fails loudly the moment a new module isn't registered (or a stale one lingers). +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" + + +def _source_modules() -> set[str]: + return {p.stem for p in SCRIPTS.glob("*.py")} + + +def test_pyproject_py_modules_match_scripts(): + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + declared = set(data["tool"]["setuptools"]["py-modules"]) + source = _source_modules() + missing = source - declared # on disk but not packaged -> wheel/frozen import error + phantom = declared - source # declared but no file -> stale entry + assert not missing, f"pyproject py-modules missing source modules: {sorted(missing)}" + assert not phantom, f"pyproject py-modules has phantom entries (no .py): {sorted(phantom)}" + + +def test_pyinstaller_spec_lists_all_modules(): + spec = (ROOT / "installer" / "fastflowprompt.spec").read_text(encoding="utf-8") + missing = [m for m in sorted(_source_modules()) if f'"{m}"' not in spec] + assert not missing, f"fastflowprompt.spec HIDDEN_IMPORTS missing: {missing}"