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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- **Hardware-aware model suggestions.** The dashboard detects system RAM (GlobalMemoryStatusEx) and GPU VRAM (nvidia-smi, or the display-class registry's qwMemorySize — which also finds Ryzen AI iGPU carve-outs) and computes a per-provider size budget: FastFlowLM scales with installed RAM (32 GB ≈ 4B-class on the NPU, 64 GB ≈ 9B), Ollama with VRAM (e.g. 8 GB ≈ 9B) or conservatively with RAM on CPU-only boxes. The pull card shows the detected budget, suggestions hide models that don't fit (near-misses are marked "tight fit"), and free-typing an oversized model asks for confirmation. New daemon action `model_recommendations`; new module `ffp_hardware`.
- **Chat moved into the web dashboard.** Chat is now a Chat tab in the daemon-served dashboard — `Ctrl+Shift+T` and the tray "Open Chat" open it, and `Ctrl+Shift+A` sends the current selection there (prefilled). Threads, history, and the "ground answers in my notes" toggle work as before; the standalone tkinter chat popup is retired. The dashboard also deep-links by URL hash (`/#chat`). New module `ffp_chat`; new daemon actions `chat_threads_list` / `chat_thread_get` / `chat_send` / `chat_thread_delete` / `chat_stage_selection` / `chat_take_staged`.
- **Read, re-file, and delete notes from the dashboard.** The Notes tab is now an organizer: click a note to read its full body and source link, move it to a different bucket (the LLM's pick is no longer final), or delete it. New daemon actions `note_get` / `note_move` / `note_delete` — all vault-contained and path-traversal guarded.
- **Notification settings + a notifications feed.** A new Notifications card in Config controls desktop toasts: a master on/off, per-event toggles (action results, clipboard suggestions, settings changes, updates, diagnostics, app lifecycle, errors), a configurable dedupe window, Do-Not-Disturb, and quiet hours — errors and warnings always come through while DND / quiet hours are on. Every notification (shown *or* muted) is recorded and surfaced as a feed in the Telemetry tab. All policy lives in the daemon (`ffp_notifications`), which classifies each toast by message pattern, so the ~45 existing notification call sites were left untouched; the AHK front-end consults the daemon and fails *open* (shows the toast) when the daemon is unreachable. New config block `notifications`, new module `ffp_notifications`, new daemon actions `notify_gate` (decide + log) and `notifications_log` (read).

### Fixed

Expand Down
16 changes: 16 additions & 0 deletions config/grammar_hotkey.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@
"dictionary": {
"protected_words": []
},
"notifications": {
"enabled": true,
"dedupe_seconds": 5,
"dnd": false,
"log_enabled": true,
"quiet_hours": { "enabled": false, "start": "22:00", "end": "07:00" },
"categories": {
"errors": { "enabled": true },
"clipboard_suggestions": { "enabled": true },
"updates": { "enabled": true },
"diagnostics": { "enabled": true },
"settings": { "enabled": true },
"lifecycle": { "enabled": true },
"action_result": { "enabled": true }
}
},
"hotkeys": {
"grammar_fix": "^+g",
"open_chat": "^!c",
Expand Down
60 changes: 60 additions & 0 deletions scripts/ffp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@
"dictionary": {
"protected_words": [],
},
"notifications": {
"enabled": True,
"dedupe_seconds": 5,
"dnd": False,
"log_enabled": True,
"quiet_hours": {"enabled": False, "start": "22:00", "end": "07:00"},
"categories": {
"errors": {"enabled": True},
"clipboard_suggestions": {"enabled": True},
"updates": {"enabled": True},
"diagnostics": {"enabled": True},
"settings": {"enabled": True},
"lifecycle": {"enabled": True},
"action_result": {"enabled": True},
},
},
"modes": {
"grammar": {
"label": "Grammar fix",
Expand Down Expand Up @@ -313,6 +329,46 @@ def normalize_llm_config(cfg: dict, *, prefer_legacy: bool = False) -> dict:
_PATCH_HOTKEYS_KEYS = frozenset({"ask_chat", "capture_note", "grammar_fix", "open_chat"})
_PATCH_TONE_KEYS = frozenset({"preset"})
_PATCH_PROVIDER_PROFILE_KEYS = frozenset({"base_url", "model", "auth_bearer", "timeout_seconds", "auto_start"})
# Notification categories accepted in a patch. Keep in sync with
# ffp_notifications.CATEGORIES (a test guards against drift).
_PATCH_NOTIF_CATEGORIES = frozenset({
"errors", "clipboard_suggestions", "updates", "diagnostics",
"settings", "lifecycle", "action_result",
})
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):[0-5]\d$")


def _filter_notifications_patch(value: dict) -> dict:
"""Whitelist + type-coerce the notifications settings block."""
out: dict = {}
for flag in ("enabled", "dnd", "log_enabled"):
if flag in value:
out[flag] = bool(value[flag])
if "dedupe_seconds" in value:
try:
out["dedupe_seconds"] = max(0.0, min(float(value["dedupe_seconds"]), 3600.0))
except (TypeError, ValueError):
pass
quiet = value.get("quiet_hours")
if isinstance(quiet, dict):
filtered_quiet: dict = {}
if "enabled" in quiet:
filtered_quiet["enabled"] = bool(quiet["enabled"])
for time_key in ("start", "end"):
tv = quiet.get(time_key)
if isinstance(tv, str) and _HHMM_RE.match(tv):
filtered_quiet[time_key] = tv
if filtered_quiet:
out["quiet_hours"] = filtered_quiet
cats = value.get("categories")
if isinstance(cats, dict):
filtered_cats: dict = {}
for cat_id, cat_val in cats.items():
if cat_id in _PATCH_NOTIF_CATEGORIES and isinstance(cat_val, dict) and "enabled" in cat_val:
filtered_cats[cat_id] = {"enabled": bool(cat_val["enabled"])}
if filtered_cats:
out["categories"] = filtered_cats
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
Expand Down Expand Up @@ -392,6 +448,10 @@ def filter_config_patch(patch: dict) -> dict:
filtered = {k: v for k, v in value.items() if k in _PATCH_NOTES_KEYS}
if filtered:
out[key] = filtered
elif key == "notifications" and isinstance(value, dict):
filtered = _filter_notifications_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
23 changes: 23 additions & 0 deletions scripts/ffp_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
sys.path.insert(0, str(HERE))

import ffp_config # noqa: E402
import ffp_notifications # noqa: E402
import ffp_notify # noqa: E402
import ffp_provider_runtime # noqa: E402
import grammar_fix # noqa: E402
Expand Down Expand Up @@ -558,6 +559,25 @@ def _act_notify(args: dict) -> str:
return "queued"


def _act_notify_gate(args: dict) -> dict:
"""Decide whether the AHK front-end should display a toast, and log the
decision. Returns {show, reason, category}; AHK shows iff show is true (and
fails open — shows anyway — when this daemon is unreachable). All policy
(per-category on/off, dedupe, quiet-hours, DND) lives in ffp_notifications."""
title = str(args.get("title") or "").strip() or "Flowkey"
message = str(args.get("message") or "")
return ffp_notifications.gate(title, message, config=grammar_fix.load_config())


def _act_notifications_log(args: dict) -> list[dict]:
"""Recent notification decisions (newest first) for the Telemetry feed."""
try:
limit = int(args.get("limit") or 50)
except (TypeError, ValueError):
limit = 50
return ffp_notifications.read_log(max(1, min(limit, 500)))


def _act_open_dashboard(_args: dict) -> str:
"""Signal the AHK front-end to open the dashboard (marker file)."""
try:
Expand Down Expand Up @@ -659,6 +679,8 @@ def _act_chat_take_staged(_args: dict) -> dict:
"pull_start": _act_pull_start,
"pull_status": _act_pull_status,
"notify": _act_notify,
"notify_gate": _act_notify_gate,
"notifications_log": _act_notifications_log,
"save_note": _act_save_note,
"chat_threads_list": _act_chat_threads_list,
"chat_thread_get": _act_chat_thread_get,
Expand All @@ -683,6 +705,7 @@ def _act_chat_take_staged(_args: dict) -> dict:
"set_autostart", "bench_start", "pull_start",
"chat_send", "chat_thread_delete", "chat_stage_selection", "chat_take_staged",
"note_move", "note_delete",
"notify_gate", # writes the notifications log + updates dedupe state
}

_shutdown_event = threading.Event()
Expand Down
Loading