Repository navigation
Expand file tree
/
Copy pathdaemon.py
More file actions
2672 lines (2420 loc) · 122 KB
/
Copy pathdaemon.py
File metadata and controls
2672 lines (2420 loc) · 122 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Main orchestrator: hotkey -> record -> transcribe -> correct -> cleanup -> paste."""
from __future__ import annotations
import os
import sys
import threading
import time
from dataclasses import dataclass, field, replace
from pathlib import Path
_MAX_LOG_BYTES = 5 * 1024 * 1024 # rotate at 5MB
_LOG_KEEP = 3 # keep .log + .log.1..N
def _rotate_log(log_path: Path) -> None:
"""Simple rotation: openflow.log -> .log.1, .log.1 -> .log.2, etc."""
try:
if not log_path.exists() or log_path.stat().st_size < _MAX_LOG_BYTES:
return
for i in range(_LOG_KEEP, 0, -1):
src = log_path.with_suffix(f".log.{i - 1}" if i > 1 else ".log")
dst = log_path.with_suffix(f".log.{i}")
if src.exists():
if dst.exists():
dst.unlink()
src.rename(dst)
except Exception:
pass
def _install_file_logger() -> None:
"""Capture print() output to ~/.openflow/openflow.log without replacing
sys.stdout/stderr (Cocoa inspects fileno on those and SIGABRTs if they
aren't real fds). We monkey-patch the builtin print to also write to file.
Each line is timestamped; the file rotates at 5MB.
"""
import openflow_logger # one source for the log path (tests redirect it)
log_path = Path(openflow_logger._MAIN_LOG)
log_path.parent.mkdir(parents=True, exist_ok=True)
_rotate_log(log_path)
log_file = open(log_path, "a", buffering=1, encoding="utf-8")
log_file.write(f"\n--- daemon start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
log_file.flush()
import builtins
_original_print = builtins.print
def print_and_log(*args, **kwargs):
# Strip 'file' kwarg targeting non-default streams; only mirror stdout/stderr defaults.
out_file = kwargs.get("file")
if out_file in (None, sys.stdout, sys.stderr):
try:
msg = kwargs.get("sep", " ").join(str(a) for a in args)
end = kwargs.get("end", "\n")
ts = time.strftime("%H:%M:%S")
log_file.write(f"[{ts}] {msg}{end}")
log_file.flush()
except Exception:
pass
return _original_print(*args, **kwargs)
builtins.print = print_and_log
# Installed by main() (the daemon's start-up), never at import: a one-off
# `import daemon` (tests, dev tools) must not write to ~/.openflow.
import collections
import json
import subprocess
import config as cfg_mod
from openflow_logger import get_logger, log_exception
_log = get_logger("daemon")
from audio import (NO_INPUT_RMS, SOFT_SPEECH_FACTOR, Recorder, RecorderConfig, heard_nothing,
heard_speech, loudest_rms, low_energy)
from transcribe import Transcriber, TranscribeOptions, is_phantom
# Use NSEvent-backed listener by default (works correctly under rumps NSApp).
# Set OPENFLOW_HOTKEYS=pynput to fall back to the legacy CGEventTap impl.
_PYNPUT = os.environ.get("OPENFLOW_HOTKEYS", "nsevent") == "pynput"
if _PYNPUT:
from hotkeys import HoldToTalk, HotkeySet, is_valid_chord, is_valid_hold_key
_ESCAPE_CHORD = "esc" # pynput's name for the key
else:
from hotkeys_nsevent import HoldToTalk, HotkeySet, is_valid_chord, is_valid_hold_key
_ESCAPE_CHORD = "escape"
from paste import (paste, get_active_app, capture_front_app, capture_paste_target,
field_snapshot, focused_editable, set_clipboard, undo_last_paste)
from ai import AIProcessor, AIConfig
from llm import make_cleanup_provider
import groq_stt
import keys
import retry_queue
import llm
from sarvam import STT_URL, warm
from dictionary import Dictionary
from autolearn import AutoLearner, Correction, PasteWatch
from paste import MAX_FIELD_CHARS, ax_field_text, ax_same_element
from snippets import Snippets
import formatting
import screen_context
import command_mode
from prompts import app_kind as prompts_app_kind
from history import STATUS_FAILED, STATUS_NO_SPEECH, UNTRANSCRIBED, History
from takes import TakeStore, network_up
from state import DaemonState, RecordingState, ToneMode, LanguageMode
from tray import TrayApp, Status
from tray import _spawn_ui_subprocess as spawn_ui
from flow_state import CARD, NOT_PASTED, WRITE_FAILED, FlowController, FlowHooks
from flow_state import OFFLINE as FLOW_OFFLINE, SAVED as FLOW_SAVED
from flow_state import PROCESSING as FLOW_PROCESSING
from flow_state import IDLE as FLOW_IDLE, RECORDING as FLOW_RECORDING, SILENT as FLOW_SILENT
from widget_channel import EDIT_OVERLAY_SOCKET_PATH, WidgetServer
from control_channel import ControlServer
from config_apply import plan_changes, resolve_hotkeys
import hang_watch
import sounds
# Cycling order — preserve pre-refactor sequence.
_TONE_CYCLE: list[ToneMode] = [
ToneMode.RAW, ToneMode.VERBATIM, ToneMode.CASUAL, ToneMode.PROFESSIONAL,
ToneMode.BULLETS, ToneMode.EMAIL, ToneMode.SLACK,
]
_LANG_CYCLE: list[LanguageMode] = [
LanguageMode.AUTO, LanguageMode.EN, LanguageMode.HI, LanguageMode.HI_ROMAN,
LanguageMode.HINGLISH, LanguageMode.HI_TO_EN, LanguageMode.EN_TO_HI,
]
# A hold's start tick waits this long, and is skipped if the key is up by
# then: that press was a tap, most likely the first half of a double-tap,
# which should sound only the hands-free cue. Waiting the full SHORT_TAP_MS
# (450 ms) would rule every tap out but lag every hold; 200 ms covers about
# two thirds of real taps (log, 2026-10-01: 105–313 ms, median ~190). A longer
# tap's tick is cut off when the hands-free cue plays (sounds._SUPERSEDES).
HOLD_CUE_DELAY_S = 0.2
# A key event's own timestamp is used for latency timing only if it is at
# most this old when handled (else the clocks disagree: handler time). Was
# 5 s, which hid exactly the stalls worth seeing.
EVENT_TIME_MAX_AGE_S = 60.0
# A tap's mic stays open this much past the double-tap window, so a second
# press whose handler runs a little late still reuses it (audio.cancel).
TAP_LINGER_SLACK_S = 0.15
# A PortAudio open slower than this is logged as a warning (normally ~0.1 s;
# 5-10 s when CoreAudio was stuck starting a dead output device).
SLOW_MIC_OPEN_S = 1.0
# Hands-free safety stop: a double-tap session (or the edit hotkey's take)
# still recording after this long is stopped through the normal ✓ path, so
# its text is transcribed and pasted. An accidental toggle used to record
# until noticed (longest take in the log: 158.7 s).
HANDS_FREE_MAX_S = 6 * 60.0
# Widget pump cadence (spec 2026-10-05-footprint). The mic level only
# matters while recording; Undo / Retry / card timers need a few ticks a
# second; at rest the pump only checks config.toml (every CFG_CHECK_S) and
# the widget watchdog, and any widget state change wakes it at once.
PUMP_LEVEL_S = 0.05
PUMP_TICK_S = 0.25
PUMP_IDLE_S = 1.0
CFG_CHECK_S = 2.0
def _after(delay_s: float, fn) -> None:
t = threading.Timer(delay_s, fn)
t.daemon = True
t.start()
def _coerce_tone(s: str) -> ToneMode:
try:
return ToneMode(s)
except ValueError:
return ToneMode.PROFESSIONAL
def _coerce_lang(s: str) -> LanguageMode:
try:
return LanguageMode(s)
except ValueError:
return LanguageMode.AUTO
def _child_env() -> dict:
"""Env for UI subprocesses. The daemon (launched via LaunchServices)
carries per-instance LS vars; if a child app process inherits them,
WindowServer treats it as part of the daemon's app instance and its
windows never come onscreen. Strip them so each overlay registers as
its own app."""
env = os.environ.copy()
for k in ("__CFBundleIdentifier", "LaunchInstanceID",
"XPC_SERVICE_NAME", "XPC_FLAGS"):
env.pop(k, None)
return env
def _app_bundle_path() -> str:
"""/Applications/OpenFlow.app derived from the frozen executable path."""
# sys.executable = .../OpenFlow.app/Contents/MacOS/openflow
return str(Path(sys.executable).resolve().parents[2])
def _spawn_ui(subcommand: list[str], dev_script: str,
dev_args: tuple = ()) -> subprocess.Popen | None:
"""Launch a UI surface subprocess.
Frozen: route through LaunchServices (`open -n -g`) — windows of a
directly-exec'd child of this daemon never come onscreen (WindowServer
treats the child as a windowless helper of our instance). LS launch
registers it as a real app so overlays actually render.
Dev: plain interpreter subprocess.
"""
try:
if getattr(sys, "frozen", False):
cmd = ["/usr/bin/open", "-n", "-g", "-a", _app_bundle_path(),
"--args", *subcommand]
else:
repo_root = Path(__file__).resolve().parent
cmd = [sys.executable, str(repo_root / dev_script), *dev_args]
return subprocess.Popen(cmd, env=_child_env())
except Exception as e:
print(f"[daemon] spawn {subcommand} failed: {e}", flush=True)
return None
def _spawn_flow_widget() -> subprocess.Popen | None:
return _spawn_ui(["flow-widget"], "ui/flow_widget.py")
# A live rebind installs the new key monitors after this long (start-up uses
# the listener's own, longer delay so it doesn't race NSApp).
REBIND_INSTALL_DELAY_S = 0.3
def _hotkey_is_valid(action: str, value: str) -> bool:
if action == "record_hold":
return is_valid_hold_key(value)
return value == "" or is_valid_chord(value) # empty = unbound
def _config_mtime() -> float:
try:
return cfg_mod.CONFIG_PATH.stat().st_mtime
except OSError:
return 0.0
def _file_mtime(path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
@dataclass
class RunContext:
"""What a pipeline run needs to be replayed by Undo/Retry. FlowController
keeps it opaque, as the `target` it stores next to the audio."""
target: object = None # paste target captured at key-down
edit_mode: bool = False
selection: str = "" # edit-mode selection the run applies to
# Latency bookkeeping for the live run only (Undo/Retry clear it):
keyup_at: float | None = None # time.monotonic() of the key-up (its event time)
record_s: float | None = None # stop handler -> audio in hand (recorder.stop)
keyup_queue_s: float | None = None # key-up event -> its stop handler ran
stream: object = None # the take's StreamingSession, if streamed
# Names on screen at key-down (screen_context.py); in memory only.
screen_terms: tuple[str, ...] = ()
# Command mode (edit hotkey, nothing selected): its command_mode.Pending.
command: object = None
# Never lose a word: the take's audio on disk (takes.py) until its text
# lands, and its history row once a failure was recorded there.
audio_path: str | None = None
history_id: int | None = None
# The take's audio being written (a _SaveJob) while it is transcribed.
saving: object = None
class _SaveJob:
"""Writes a take's audio (TakeStore.save: WAV + fsync) on its own thread,
so the STT race starts at once instead of after the disk write."""
def __init__(self, store, audio, sample_rate: int) -> None:
self._path: str | None = None
self._thread = threading.Thread(target=self._run, args=(store, audio, sample_rate),
name="take-save", daemon=True)
self._thread.start()
def _run(self, store, audio, sample_rate: int) -> None:
self._path = store.save(audio, sample_rate)
def wait(self, timeout: float | None = None) -> str | None:
"""The saved path (None: not saved, or still writing at the timeout)."""
self._thread.join(timeout)
return None if self._thread.is_alive() else self._path
class _PastePrefetch:
"""The paste's AX reads, done while the take is being transcribed: is
the key-down element a text field (focused_editable), and the field's
text before Cmd+V (paste verification). Both depend only on the paste
target captured at key-down, so they needn't wait for the transcript."""
def __init__(self, target, check_editable: bool) -> None:
self.target = target
self.editable = None
self.editable_known = False
self.before = None
self.before_known = False
self._check_editable = check_editable
self._done = threading.Event()
threading.Thread(target=self._run, name="paste-prefetch", daemon=True).start()
def _run(self) -> None:
try:
# Only the element captured at key-down: with none, focused_editable
# reads the focus of the moment, which must be read at paste time.
if self._check_editable and getattr(self.target, "ax_element", None) is not None:
self.editable = focused_editable(self.target)
self.editable_known = True
self.before = field_snapshot(self.target)
self.before_known = True
except Exception as e:
print(f"[daemon] paste prefetch skipped: {e}", flush=True)
finally:
self._done.set()
def ready(self, timeout: float) -> "_PastePrefetch | None":
return self if self._done.wait(timeout) else None
# A prefetch not finished by paste time is waited on this long, then ignored
# (the paste reads for itself, as before).
PREFETCH_WAIT_S = 0.1
# The worker waits at most this long for a take's audio to reach the disk
# before discarding it. A write still running past it finishes later and
# leaves its file behind, which the next start lists as a failed take: the
# words are never lost, only kept longer than needed.
SAVE_WAIT_S = 10.0
class _WidgetSender:
"""Writes to the widget socket on one thread, in order. _send_widget
runs inside the FlowController lock (and on the key thread); a widget
process that stops reading made every state change wait out the
socket's 1 s send timeout there, holding the lock that the key thread,
the pump and the widget's own clicks all need. Callers now only queue.
A level message replaces a level still waiting, so a stalled widget
can't build up a backlog of stale meter readings."""
def __init__(self, server) -> None:
self._server = server
self._q: "collections.deque[dict]" = collections.deque()
self._cv = threading.Condition()
self._busy = False
threading.Thread(target=self._run, name="widget-send", daemon=True).start()
def send(self, msg: dict) -> None:
with self._cv:
if msg.get("type") == "level" and self._q and self._q[-1].get("type") == "level":
self._q[-1] = msg
else:
self._q.append(msg)
self._cv.notify_all()
def flush(self, timeout: float) -> bool:
"""Wait until everything queued has been written (quit, tests)."""
with self._cv:
return self._cv.wait_for(lambda: not self._q and not self._busy, timeout)
def _run(self) -> None:
while True:
with self._cv:
self._cv.wait_for(lambda: bool(self._q))
msg = self._q.popleft()
self._busy = True
try:
self._server.send(msg)
except Exception as e: # never let the sender thread die
print(f"[daemon] widget send failed: {e}", flush=True)
finally:
with self._cv:
self._busy = False
self._cv.notify_all()
# Quitting waits at most this long for the widget's last messages ("exit").
WIDGET_FLUSH_S = 1.0
class _WidgetWatchdog:
"""Respawns the widget process while it is not connected, with
exponential backoff so a widget that crashes at startup doesn't
relaunch the app every few seconds forever."""
GRACE_S = 5.0 # disconnected this long before (re)spawning
FIRST_DELAY_S = 10.0
MAX_DELAY_S = 300.0
STABLE_S = 30.0 # a connection this long resets the backoff
def __init__(self, spawn, clock=time.monotonic) -> None:
self._spawn = spawn
self._clock = clock
now = clock()
self._last_seen = now - self.GRACE_S # spawn right away
self._connected_since: float | None = None
self._next_spawn_at = now
self._delay = self.FIRST_DELAY_S
self._cap_logged = False
def poll(self, connected: bool) -> None:
now = self._clock()
if connected:
self._last_seen = now
if self._connected_since is None:
self._connected_since = now
elif now - self._connected_since >= self.STABLE_S:
self._delay = self.FIRST_DELAY_S
self._next_spawn_at = now
self._cap_logged = False
return
self._connected_since = None
if now - self._last_seen < self.GRACE_S or now < self._next_spawn_at:
return
print("[daemon] flow widget not connected — spawning", flush=True)
self._spawn()
self._next_spawn_at = now + self._delay
self._delay = min(self._delay * 2, self.MAX_DELAY_S)
if self._delay >= self.MAX_DELAY_S and not self._cap_logged:
self._cap_logged = True
print(f"[daemon] flow widget keeps failing to connect — respawn "
f"backoff capped at {self.MAX_DELAY_S:.0f}s", flush=True)
_ONBOARD_FLAG = Path(os.path.expanduser("~/.openflow/onboarded.flag"))
_FIRST_RUN_CONFIG = Path(os.path.expanduser("~/.openflow/config.toml"))
_FIRST_RUN_POLL_S = 0.25
def _maybe_run_onboarding_blocking() -> None:
"""If first run, open the first-run window (ui/first_run.py) and wait.
Daemon won't proceed to tray init until setup is done, so the user
always sees permissions and the Sarvam key before any hotkey works.
The window writes the flag when its last step ("Try it") opens; the
daemon starts then, with the window still up, so the user's first
dictation lands in its box. Closing the window early also returns.
"First run" = onboarded.flag missing AND no existing config.toml
(existing users from before the wizard shipped get auto-flagged).
"""
if _ONBOARD_FLAG.exists():
return
if _FIRST_RUN_CONFIG.exists():
# Existing user predating the wizard — mark them onboarded silently.
try:
_ONBOARD_FLAG.parent.mkdir(parents=True, exist_ok=True)
_ONBOARD_FLAG.write_text("auto-migrated")
except Exception:
pass
return
print("[daemon] first run — opening the first-run window", flush=True)
try:
if getattr(sys, "frozen", False):
cmd = [sys.executable, "onboarding"]
else:
repo_root = Path(__file__).resolve().parent
cmd = [sys.executable, str(repo_root / "ui" / "first_run.py")]
proc = subprocess.Popen(cmd, env=_child_env())
except Exception as e:
print(f"[daemon] first-run launch failed: {e}", flush=True)
return
while proc.poll() is None and not _ONBOARD_FLAG.exists():
time.sleep(_FIRST_RUN_POLL_S)
# The overlay process takes a moment to start and connect: no second spawn
# for it in the meantime (it gets the latest view when it connects).
OVERLAY_SPAWN_WAIT_S = 3.0
def _spawn_edit_overlay() -> subprocess.Popen | None:
"""Spawn the PyQt6 edit-mode overlay (subprocess, never blocks). It
connects back over EDIT_OVERLAY_SOCKET_PATH for the selection."""
return _spawn_ui(["edit-overlay"], "ui/edit_overlay.py")
class Daemon:
# A dictation that arrives while another is processing queues behind it
# (run ids keep the widget right); only a stuck pipeline makes it fail.
_BUSY_WAIT_S = 60.0
def __init__(self) -> None:
self.cfg = cfg_mod.load()
snd = self.cfg.get("sounds") or {}
sounds.configure(enabled=snd.get("enabled", True),
volume=snd.get("volume", sounds.DEFAULT_VOLUME))
self.state = DaemonState(
tone=_coerce_tone(self.cfg["general"]["default_tone"]),
language=_coerce_lang(self.cfg["general"]["default_language"]),
)
self.recorder = Recorder(
RecorderConfig(
sample_rate=self.cfg["audio"]["sample_rate"],
device=self.cfg["audio"].get("device", "default"),
)
)
sarvam_cfg = self.cfg.get("sarvam") or {}
# Fallback provider keys: read once now (any Keychain prompt shows at
# launch, not mid-dictation), then served from memory.
self._keys = keys.KeyCache()
keys.current = self._keys
self._keys.start(self.cfg)
self.transcriber = Transcriber(
model=sarvam_cfg.get("stt_model", "saaras:v4"),
api_key_env=sarvam_cfg.get("api_key_env", "SARVAM_API_KEY"),
streaming=sarvam_cfg.get("streaming", "auto"),
# Provider failover: Groq Whisper after Sarvam ([failover] read per take).
fallback=lambda: keys.fallback_stt(self.cfg, self._keys),
)
self._stream_enabled = True # see _open_stream
self._stream = None
threading.Thread(
target=self.transcriber.preload, name="sarvam-preload", daemon=True
).start()
self.ai = AIProcessor(AIConfig(
model=sarvam_cfg.get("chat_model", "sarvam-105b"),
max_tokens=int(sarvam_cfg.get("max_tokens", 1024)),
api_key_env=sarvam_cfg.get("api_key_env", "SARVAM_API_KEY"),
), provider=llm.with_failover(make_cleanup_provider(self.cfg), self.cfg,
find_key=self._keys.find))
print(f"[daemon] cleanup LLM: {self.ai.provider.name} "
f"({self.ai.provider.model})", flush=True)
self._warm_enabled = True # see _warm_up
self._screen_enabled = True # see _start_screen_capture
self._prefetch_enabled = True # see _prefetch_paste
self.dictionary = Dictionary.load()
self._dict_mtime = _file_mtime(cfg_mod.DICT_PATH)
self._autolearner = AutoLearner(cfg_mod.SUGGESTIONS_PATH, cfg_mod.DICT_PATH,
on_learned=self._on_learned)
self._paste_watch: PasteWatch | None = None
self.snippets = Snippets.load()
self.history = History()
# Never lose a word: every take's audio is kept until its text lands.
# Test daemons (built without __init__) have no store and never write.
self._takes = TakeStore()
self._net_up = lambda: network_up(STT_URL)
self._takes_since = time.time()
threading.Thread(target=self._tidy_takes, name="takes-prune", daemon=True).start()
# Failed takes transcribe themselves once the network is back.
self._auto_retry = retry_queue.for_daemon(self)
self._prune_history()
self._busy = threading.Lock()
self._hold: HoldToTalk | None = None
self._chords: HotkeySet | None = None
self._edit_pending = False # for edit-mode
self._command = None # armed command_mode.Pending (edit hotkey, no selection)
self._edit_take = None # command_mode.Take: the edit hotkey's listening take
self._tray: TrayApp | None = None
self._stop_evt = threading.Event()
self._pump_wake = threading.Event() # widget state changed: pump now
self._cancel_pending = False
self._build_flow_widget()
# Last non-OpenFlow frontmost app — paste target (Wispr-style).
self._paste_target = None
# -- Mode switching --------------------------------------------------
def cycle_tone(self) -> None:
i = _TONE_CYCLE.index(self.state.tone) if self.state.tone in _TONE_CYCLE else 0
self.state.tone = _TONE_CYCLE[(i + 1) % len(_TONE_CYCLE)]
self.state.notify()
print(f"[daemon] tone -> {self.state.tone.value}", flush=True)
def cycle_lang(self) -> None:
i = _LANG_CYCLE.index(self.state.language) if self.state.language in _LANG_CYCLE else 0
self.state.language = _LANG_CYCLE[(i + 1) % len(_LANG_CYCLE)]
self.state.notify()
print(f"[daemon] lang -> {self.state.language.value}", flush=True)
def set_tone(self, tone: ToneMode) -> None:
if self.state.tone == tone:
return
self.state.tone = tone
self.state.notify()
print(f"[daemon] tone -> {tone.value}", flush=True)
def set_language(self, lang: LanguageMode) -> None:
if self.state.language == lang:
return
self.state.language = lang
self.state.notify()
print(f"[daemon] lang -> {lang.value}", flush=True)
def _prune_history(self) -> None:
"""Retention at start: [history] keep_days and size_cap (also
applied on every save). Never fatal."""
try:
hist_cfg = {**cfg_mod.DEFAULTS["history"], **(self.cfg.get("history") or {})}
n = self.history.prune(keep_days=int(hist_cfg.get("keep_days") or 0),
cap=int(hist_cfg["size_cap"]))
if n:
print(f"[daemon] history: pruned {n} old dictations", flush=True)
except Exception as e:
log_exception("daemon.history", "history prune failed", e)
def shutdown(self) -> None:
self._stop_evt.set()
self._wake_pump()
def close_ui(self) -> None:
"""Quitting from the Dock / ⌘Q ends the process inside AppKit, so the
run loop's cleanup never runs: tell the widget and edit overlay to
go now (best effort)."""
try:
self._send_widget({"type": "exit"})
except Exception as e:
log_exception("daemon", "could not close the flow widget", e)
try:
if getattr(self, "_edit_overlay", None) is not None:
self._edit_overlay.stop()
except Exception as e:
log_exception("daemon", "could not close the edit overlay", e)
# -- Pipeline pieces -------------------------------------------------
def _stt_opts(self, tone: ToneMode | None = None,
language: LanguageMode | None = None) -> TranscribeOptions:
"""Map OpenFlow language mode → Saaras language_code + mode."""
m = (language or self.state.language).value
always_en = self.cfg["general"].get("always_english_output", True)
sr = int(self.cfg["audio"].get("sample_rate", 16000))
tone_raw = (tone or self.state.tone).value == "raw"
silence = float(self.cfg["audio"].get("silence_threshold", 0.01))
def _opts(language_code: str | None, mode: str) -> TranscribeOptions:
if tone_raw and mode == "transcribe":
mode = "verbatim"
return TranscribeOptions(
language_code=language_code,
mode=mode,
sample_rate=sr,
silence_threshold=silence,
)
# Global override: collapse every input language to English, except
# modes that exist specifically to emit Hindi / Roman Hindi.
if always_en and m not in ("en_to_hi", "hi_roman", "hi"):
if m == "en":
return _opts("en-IN", "transcribe")
if m == "hi_to_en":
return _opts("hi-IN", "translate")
return _opts("unknown", "translate")
if m == "en":
return _opts("en-IN", "transcribe")
if m == "hi":
script = self.cfg["general"].get("hindi_script", "devanagari")
return _opts("hi-IN", "translit" if script == "roman" else "transcribe")
if m == "hi_roman":
return _opts("hi-IN", "translit")
if m == "hinglish":
return _opts("unknown", "codemix")
if m == "hi_to_en":
return _opts("hi-IN", "translate")
if m == "en_to_hi":
return _opts("en-IN", "transcribe")
# auto: detect EN / HI / mix; codemix keeps English words in Latin
# and Hindi in Devanagari — the daily Hinglish path.
return _opts("unknown", "codemix")
def _style_examples(self) -> str | None:
try:
rows = self.history.recent(limit=12)
except Exception:
return None
parts: list[str] = []
for r in rows:
raw = (r.raw or "").strip()
final = (r.final or "").strip()
if not raw or not final or raw == final:
continue
parts.append(f"raw: {raw}\npreferred: {final}")
if len(parts) >= 4:
break
return "\n\n".join(parts) if parts else None
def _inject_glossary(self) -> bool:
d = self.cfg.get("dictionary") or {}
if "inject_into_cleanup" in d:
return bool(d["inject_into_cleanup"])
return True
def _tone_for(self, target) -> ToneMode:
"""Tone for a dictation into `target`: its [apps.tones] entry stands
in for the default tone. A tone switched to for the session (F6, the
hub), i.e. not the configured default, is an explicit choice and wins."""
tone = self.state.tone
default = (self.cfg.get("general") or {}).get("default_tone")
if default is not None and tone.value != default:
return tone
name = getattr(target, "name", None)
override = cfg_mod.app_tone(self.cfg.get("apps"), name,
getattr(target, "bundle_id", None))
if override is None:
return tone
try:
return ToneMode(override)
except ValueError:
self._warn(f"apps.tones: {name!r} = {override!r} is not a tone — using {tone.value}")
return tone
def _context_app(self, target) -> str | None:
"""App name for the cleanup prompt's context hint, if enabled."""
if not (self.cfg.get("apps") or {}).get("context_hints", True):
return None
return getattr(target, "name", None) or get_active_app()
def _active_snippets(self) -> Snippets | None:
"""The snippet store, re-read if snippets.json changed; None when
snippets are off or there are none."""
snips = getattr(self, "snippets", None)
if snips is None or not (self.cfg.get("snippets") or {}).get("enabled", True):
return None
snips.refresh()
return snips if snips.items else None
def _trivial(self, text: str, tone: str) -> bool:
"""Short enough that the cleanup LLM can only add latency: at most
[cleanup] skip_max_words words (Saaras has already punctuated it).
Bullets still go, since even three words become a list."""
limit = int((self.cfg.get("cleanup") or {}).get(
"skip_max_words", cfg_mod.DEFAULTS["cleanup"]["skip_max_words"]))
return tone != "bullets" and 0 < len(text.split()) <= limit
def _auto_format(self) -> bool:
return bool((self.cfg.get("formatting") or {}).get(
"auto", cfg_mod.DEFAULTS["formatting"]["auto"]))
def _format_verbatim(self, text: str, *, email: bool = False) -> str:
"""Verbatim auto-formatting: Python lays out what it can; a model
is called only for what it can't (paragraphs in a long dictation, a
list whose end is unclear), and its result is kept only if it has
exactly the transcript's words, less the spoken list cues."""
local = formatting.format_local(text, email=email)
if not local.model_tasks:
return local.text
src = local.text
if local.model_tasks == ["paragraphs"]:
return self._paragraphs_verbatim(src)
try:
out = self.ai.format_only(src, local.model_tasks)
except Exception as e:
log_exception("daemon.pipeline", "formatting call failed — pasting unformatted", e)
return src
if out and formatting.same_words(src, out, formatting.removable_spans(src)):
print(f"[daemon] formatted ({', '.join(local.model_tasks)})", flush=True)
return out.strip()
print("[daemon] formatting changed words — pasting unformatted", flush=True)
return src
def _paragraphs_verbatim(self, src: str) -> str:
"""Paragraph breaks in a long verbatim dictation: the model names the
sentences that start a paragraph, Python inserts the breaks
(formatting.break_paragraphs), so the words can't change."""
numbered, count = formatting.numbered_sentences(src)
if count < 2:
return src
try:
reply = self.ai.paragraph_starts(numbered)
except Exception as e:
log_exception("daemon.pipeline", "formatting call failed — pasting unformatted", e)
return src
starts = formatting.parse_paragraph_starts(reply, count)
print(f"[daemon] formatted (paragraphs at {starts or 'none'})", flush=True)
return formatting.break_paragraphs(src, starts)
def _post_process(self, raw: str, tone: ToneMode | None = None,
language: LanguageMode | None = None, target=None,
*, skip_trivial: bool = True,
screen_terms: tuple[str, ...] = ()) -> str:
"""Dictionary + cleanup for the current modes, or the given ones
(control `rerun`, which always cleans up: the user asked for it).
target: the paste target, for the app context."""
if not raw:
return ""
threshold = int(self.cfg["dictionary"].get("fuzzy_threshold", 85))
corrected = self.dictionary.correct(raw, threshold=threshold)
if screen_terms:
corrected = screen_context.correct(corrected, list(screen_terms), threshold)
# Snippets (snippets.py): a dictation that is only a trigger pastes
# the stored text; otherwise triggers ride through as placeholders
# and done() puts the stored text back.
snips, slots = self._active_snippets(), []
if snips is not None:
whole = snips.whole(corrected)
if whole is not None:
print("[daemon] snippet: whole dictation is a trigger", flush=True)
return whole
corrected, slots = snips.protect(corrected)
def done(out: str) -> str:
if not slots:
return out
back = Snippets.restore(out, slots)
if back is None: # the model dropped or mangled a placeholder
self._warn("snippet placeholder lost in cleanup — pasting the uncleaned text")
back = Snippets.restore(corrected, slots)
return back
m_lang = (language or self.state.language).value
m_tone = (tone or self.state.tone).value
# Saaras already punctuates in transcribe/codemix/translate. Skip the
# chat hop for raw/verbatim — that's the default path and the lag.
if m_lang == "en_to_hi":
try:
return done(self.ai.translate_en_to_hi(corrected))
except Exception as e:
log_exception("daemon.pipeline", "EN→HI failed — pasting English", e)
return done(corrected)
if m_lang == "hi_roman" and any("\u0900" <= ch <= "\u097F" for ch in corrected):
try:
return done(self.ai.transliterate_to_roman(corrected))
except Exception as e:
log_exception("daemon.pipeline", "transliterate failed", e)
return done(corrected)
if m_tone == "raw":
return done(corrected) # raw is never formatted
# Auto-formatting (formatting.py): before the short-transcript skip,
# so structure always gets laid out.
email = False
structure = None
if self._auto_format():
if formatting.has_email_parts(corrected):
email = prompts_app_kind(self._context_app(target)) == "email"
structure = formatting.detect(corrected, email=email)
if m_tone == "verbatim":
if structure:
return done(self._format_verbatim(corrected, email=email))
return done(corrected)
if not structure and skip_trivial and self._trivial(corrected, m_tone):
print(f"[daemon] {len(corrected.split())}-word transcript — "
"skipping cleanup", flush=True)
return done(corrected)
glossary = None
if self._inject_glossary():
lang_for_prompt = "hi" if m_lang in ("hi", "hi_roman", "hi_to_en") else "en"
glossary = self.dictionary.initial_prompt(language=lang_for_prompt)
screen_line = screen_context.glossary_line(list(screen_terms))
if screen_line:
glossary = f"{glossary}\n\n{screen_line}" if glossary else screen_line
extra = {}
if structure:
# Spoken line breaks are applied here, not left to the model.
corrected = formatting.apply_commands(corrected)
extra["format_notes"] = formatting.notes(structure)
try:
return done(self.ai.cleanup(
corrected,
mode=m_tone,
context_app=self._context_app(target),
language=m_lang,
glossary=glossary,
examples=self._style_examples(),
**extra,
))
except Exception as e:
# Provider failover: no provider answered in its budget. Paste
# the transcript as spoken, with the layout Python can do alone.
print(f"[daemon] cleanup unavailable ({str(e)[:160]}) — "
"pasted the transcript uncleaned", flush=True)
if structure:
corrected = formatting.format_local(corrected, email=email).text
return done(corrected)
# -- Hotkey callbacks ------------------------------------------------
def _on_hold_press(self) -> None:
take = getattr(self, "_edit_take", None)
if take is not None and self.recorder.is_recording:
self._edit_take_key_press(take) # the edit hotkey's take is listening
return
# Only the hold key starts hands-free sessions; a widget click after
# a session ✓ ended must not inherit the key's stale toggle mode.
self.on_record_start(hands_free=bool(getattr(self._hold, "hands_free", False)),
pressed_at_ms=getattr(self._hold, "last_press_ms", None))
def on_record_start(self, hands_free: bool = False,
pressed_at_ms: float | None = None) -> None:
began = self._begin_record_start(hands_free, pressed_at_ms)
if began is not None:
self._finish_record_start(*began)
def _widget_start(self) -> None:
"""The widget's mic button (FlowHooks.start_recording, under the flow
lock on the widget socket's thread). The widget answers now; the mic
opens on its own thread: opened here, a slow open (10 s when
CoreAudio was stuck, 2026-10-11) left every later click, ✓ and ✕
included, unread until it was done."""
began = self._begin_record_start(False, None)
if began is not None:
self._run_record_start(lambda: self._finish_record_start(*began))
def _run_record_start(self, fn) -> None:
threading.Thread(target=fn, name="record-start", daemon=True).start()
def _begin_record_start(self, hands_free: bool,
pressed_at_ms: float | None) -> tuple[float, float] | None:
"""Claim the start and show it: (t0, key-down time), or None when
paused, already recording, or another start is still opening the mic
(a second click / press is a no-op)."""
if self.state.paused:
print("[daemon] paused — key press ignored", flush=True)
return None
with self._flow.lock:
if self.recorder.is_recording:
return None
if getattr(self, "_start_pending", False):
print("[daemon] start ignored: the mic is still opening for the last one",
flush=True)
return None
self._start_pending = True
self._stop_after_start = None
try:
print(f"[daemon] recording (tone={self.state.tone.value}, lang={self.state.language.value})...", flush=True)
t0 = time.monotonic()
# Key-down timing from the key event itself when the hotkey gave it
# (the handler can run late, behind the main thread's other work).
t_key = t0
if pressed_at_ms is not None and 0 <= t0 - pressed_at_ms / 1000.0 < EVENT_TIME_MAX_AGE_S:
t_key = pressed_at_ms / 1000.0
# Feedback first: the widget (and cue) answer the key now, not after
# the mic open below (~0.15 s), which used to leave a press looking
# ignored.
self.state.recording = RecordingState.RECORDING
self.state.notify()
with self._flow.lock: # a lone tap's delayed collapse checks the take id
self._take_id = getattr(self, "_take_id", 0) + 1
self._flow.recording_started(hands_free=hands_free)
except BaseException:
with self._flow.lock: # never leave the start claimed
self._start_pending = False
raise
return t0, t_key
def _finish_record_start(self, t0: float, t_key: float) -> None:
"""Open the mic for the claimed start, then act on a ✓ / ✕ / key-up
that came while it opened (_record_stop deferred it)."""
opened = False
try:
opened = self._open_mic_for_take(t0, t_key)
finally:
with self._flow.lock:
self._start_pending = False
stop, self._stop_after_start = self._stop_after_start, None
if opened and stop:
print(f"[daemon] {stop} came while the mic was opening — applying it now",
flush=True)
if stop == "cancel":
self._cancel_recording()
else:
self.on_record_stop()
def _open_mic_for_take(self, t0: float, t_key: float) -> bool:
"""False when no input would open (the widget says so)."""
# Then the mic, before every other key-down read: the paste target
# (AX), the screen names and the STT stream all finish while the
# user talks; the stream gets the blocks it missed (attach).
t_open = time.monotonic()
try:
reused = (bool(getattr(self.recorder, "lingering", False))
and self.recorder.resume(keep_s=t_open - t_key))
if not reused:
# No cue may start its output device while the input opens:
# CoreAudio serializes them (sounds.mic_opening).
with sounds.mic_opening():
self.recorder.start()
except Exception as e:
# No input would open (device busy / gone). Say so on screen
# instead of leaving the widget recording nothing.
log_exception("daemon.audio", "microphone failed to start", e)
self._drop_stream()
self._screen = None
self.state.recording = RecordingState.IDLE
self.state.notify()
self._flow.no_audio(None, None)
return False
t_mic = time.monotonic()
self._recording_since = t_mic # the hands-free cap counts from here
remembered = capture_paste_target()