@staff0rd/assist 0.751.0 → 0.752.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +2 -20
  2. package/allowed.cli-reads +0 -1
  3. package/allowed.cli-writes +0 -1
  4. package/claude/settings.json +1 -22
  5. package/claude/skills/review-ci/scripts/review.mjs +111 -111
  6. package/dist/allowed.cli-reads +0 -1
  7. package/dist/allowed.cli-writes +0 -1
  8. package/dist/commands/sessions/web/bundle.js +1 -1
  9. package/dist/index.js +1717 -2222
  10. package/package.json +1 -1
  11. package/claude/commands/handover.md +0 -65
  12. package/claude/commands/journal.md +0 -74
  13. package/claude/commands/recall.md +0 -55
  14. package/claude/commands/standup.md +0 -35
  15. package/claude/commands/voice-logs.md +0 -5
  16. package/claude/commands/voice-setup.md +0 -5
  17. package/claude/commands/voice-start.md +0 -5
  18. package/claude/commands/voice-status.md +0 -5
  19. package/claude/commands/voice-stop.md +0 -5
  20. package/dist/commands/voice/python/CLAUDE.md +0 -11
  21. package/dist/commands/voice/python/audio_capture.py +0 -52
  22. package/dist/commands/voice/python/check_mi.py +0 -21
  23. package/dist/commands/voice/python/dispatch.py +0 -14
  24. package/dist/commands/voice/python/keyboard.py +0 -73
  25. package/dist/commands/voice/python/list_devices.py +0 -20
  26. package/dist/commands/voice/python/logger.py +0 -38
  27. package/dist/commands/voice/python/pyproject.toml +0 -26
  28. package/dist/commands/voice/python/setup_models.py +0 -91
  29. package/dist/commands/voice/python/smart_turn.py +0 -63
  30. package/dist/commands/voice/python/stt.py +0 -54
  31. package/dist/commands/voice/python/uv.lock +0 -5946
  32. package/dist/commands/voice/python/vad.py +0 -50
  33. package/dist/commands/voice/python/voice_daemon.py +0 -528
  34. package/dist/commands/voice/python/wake_word.py +0 -26
@@ -1,50 +0,0 @@
1
- """Silero VAD wrapper (ONNX)."""
2
-
3
- import os
4
-
5
- import numpy as np
6
- import onnxruntime as ort
7
-
8
- from logger import log
9
-
10
- DEFAULT_THRESHOLD = 0.5
11
- CONTEXT_SIZE = 64 # v5/v6 requires 64 context samples prepended at 16kHz
12
-
13
-
14
- class SileroVAD:
15
- def __init__(self):
16
- model_path = os.environ.get("VOICE_MODEL_VAD")
17
- if not model_path:
18
- models_dir = os.environ.get(
19
- "VOICE_MODELS_DIR",
20
- os.path.expanduser("~/.assist/voice/models"),
21
- )
22
- model_path = os.path.join(models_dir, "silero_vad.onnx")
23
-
24
- log("vad_init", f"model={model_path}")
25
- self._session = ort.InferenceSession(
26
- model_path, providers=["CPUExecutionProvider"]
27
- )
28
- self._state = np.zeros((2, 1, 128), dtype=np.float32)
29
- self._context = np.zeros(CONTEXT_SIZE, dtype=np.float32)
30
- self._sample_rate = np.array(16000, dtype=np.int64)
31
- self.threshold = DEFAULT_THRESHOLD
32
-
33
- def process(self, audio: np.ndarray) -> float:
34
- """Process a chunk of audio, return speech probability."""
35
- chunk = audio.astype(np.float32)
36
- # Prepend context (last 64 samples from previous chunk)
37
- input_data = np.concatenate([self._context, chunk]).reshape(1, -1)
38
- ort_inputs = {
39
- "input": input_data,
40
- "state": self._state,
41
- "sr": self._sample_rate,
42
- }
43
- out, state = self._session.run(None, ort_inputs)
44
- self._state = state
45
- self._context = chunk[-CONTEXT_SIZE:]
46
- return float(out[0][0])
47
-
48
- def reset(self) -> None:
49
- self._state = np.zeros((2, 1, 128), dtype=np.float32)
50
- self._context = np.zeros(CONTEXT_SIZE, dtype=np.float32)
@@ -1,528 +0,0 @@
1
- """Voice daemon entry point — main loop and signal handling."""
2
-
3
- import ctypes
4
- import json
5
- import os
6
- import signal
7
- import subprocess
8
- import sys
9
- import time
10
-
11
- import numpy as np
12
-
13
- from audio_capture import AudioCapture, BLOCK_SIZE
14
- from logger import DEBUG, log
15
- from smart_turn import SmartTurn
16
- from stt import ParakeetSTT
17
- from vad import SileroVAD
18
- from wake_word import check_wake_word
19
-
20
- import keyboard
21
-
22
-
23
- def _load_voice_config() -> dict:
24
- """Load voice config by calling ``assist config get voice``.
25
-
26
- Sets environment variables so other Python modules (audio_capture,
27
- vad, smart_turn, stt, wake_word) can read them as before.
28
- Returns the parsed config dict.
29
- """
30
- try:
31
- result = subprocess.run(
32
- ["assist", "config", "get", "voice"],
33
- capture_output=True,
34
- text=True,
35
- check=True,
36
- shell=True,
37
- )
38
- config = json.loads(result.stdout)
39
- except (subprocess.CalledProcessError, json.JSONDecodeError) as exc:
40
- log("config_error", str(exc), level="error")
41
- return {}
42
-
43
- env_map: dict[str, str | None] = {
44
- "VOICE_WAKE_WORDS": ",".join(config.get("wakeWords", [])) or None,
45
- "VOICE_MIC": config.get("mic"),
46
- "VOICE_MODELS_DIR": os.path.expanduser(v)
47
- if (v := config.get("modelsDir"))
48
- else None,
49
- "VOICE_MODEL_VAD": (config.get("models") or {}).get("vad"),
50
- "VOICE_MODEL_SMART_TURN": (config.get("models") or {}).get("smartTurn"),
51
- }
52
- for key, value in env_map.items():
53
- if value:
54
- os.environ[key] = value
55
-
56
- log("config_loaded", json.dumps(config))
57
- return config
58
-
59
-
60
- def _get_foreground_window_info() -> str:
61
- """Return the title and process name of the currently focused window."""
62
- user32 = ctypes.windll.user32
63
- kernel32 = ctypes.windll.kernel32
64
- hwnd = user32.GetForegroundWindow()
65
-
66
- # Window title
67
- buf = ctypes.create_unicode_buffer(256)
68
- user32.GetWindowTextW(hwnd, buf, 256)
69
- title = buf.value
70
-
71
- # Process name from window handle
72
- pid = ctypes.c_ulong()
73
- user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
74
- process_name = ""
75
- PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
76
- handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
77
- if handle:
78
- exe_buf = ctypes.create_unicode_buffer(260)
79
- size = ctypes.c_ulong(260)
80
- if kernel32.QueryFullProcessImageNameW(handle, 0, exe_buf, ctypes.byref(size)):
81
- process_name = exe_buf.value.rsplit("\\", 1)[-1]
82
- kernel32.CloseHandle(handle)
83
-
84
- if process_name:
85
- return f"{process_name}: {title}"
86
- return title
87
-
88
-
89
- # States
90
- IDLE = "idle"
91
- LISTENING = "listening"
92
- ACTIVATED = "activated" # wake word heard (alone), waiting for command utterance
93
-
94
- # Max seconds of speech before forced processing
95
- MAX_SPEECH_SECONDS = 30
96
-
97
- # How often (in samples) to run partial STT during speech
98
- PARTIAL_STT_INTERVAL = 16000 # every 1 second of audio
99
-
100
- # Trailing silence (in ms) required before sending segment to smart turn.
101
- # Matches the reference implementation (record_and_predict.py STOP_MS=1000).
102
- STOP_MS = 1000
103
- STOP_CHUNKS = (STOP_MS * 16000) // (BLOCK_SIZE * 1000) # ~31 chunks
104
-
105
- # How long (seconds) to wait for a command after a wake-word-only utterance
106
- ACTIVATED_TIMEOUT = 10.0
107
-
108
-
109
- def _print_state(state: str) -> None:
110
- """Print the current daemon state to stderr when debug mode is on."""
111
- print(f"\r {state:10s}", end="", file=sys.stderr, flush=True)
112
-
113
-
114
- class VoiceDaemon:
115
- def __init__(self):
116
- self._running = True
117
- self._state = IDLE
118
- self._audio_buffer: list[np.ndarray] = []
119
-
120
- config = _load_voice_config()
121
- self._submit_windows: set[str] = set(config.get("submitWindows") or [])
122
-
123
- log("daemon_init", "Initializing models...")
124
- self._mic = AudioCapture()
125
- self._vad = SileroVAD()
126
- self._smart_turn = SmartTurn()
127
- self._stt = ParakeetSTT()
128
- if self._submit_windows:
129
- log("daemon_init", f"Submit windows: {self._submit_windows}")
130
- log("daemon_ready")
131
-
132
- # Incremental typing state
133
- self._wake_detected = False
134
- self._typed_text = ""
135
- self._last_partial_at = 0
136
- self._activated_at = 0.0
137
-
138
- def _handle_signal(self, signum, frame) -> None:
139
- log("daemon_signal", f"Received signal {signum}")
140
- self._running = False
141
-
142
- def _run_partial_stt(self) -> None:
143
- """Run STT on accumulated audio and type incrementally."""
144
- if not self._audio_buffer:
145
- return
146
-
147
- audio = np.concatenate(self._audio_buffer)
148
- text = self._stt.transcribe(audio)
149
- if not text.strip():
150
- return
151
-
152
- if DEBUG:
153
- print(f"\n Partial: {text}", file=sys.stderr)
154
-
155
- if self._state == ACTIVATED:
156
- # Already activated — everything is the command, no wake word needed
157
- partial = text.strip()
158
- if partial and partial != self._typed_text:
159
- if self._typed_text:
160
- self._update_typed_text(partial)
161
- else:
162
- keyboard.type_text(partial)
163
- self._typed_text = partial
164
- elif not self._wake_detected:
165
- found, command = check_wake_word(text)
166
- if found and command:
167
- self._wake_detected = True
168
- log("wake_word_detected", command)
169
- if DEBUG:
170
- print(f" Wake word! Typing: {command}", file=sys.stderr)
171
- keyboard.type_text(command)
172
- self._typed_text = command
173
- else:
174
- found, command = check_wake_word(text)
175
- if found and command:
176
- if command != self._typed_text:
177
- self._update_typed_text(command)
178
-
179
- def _should_submit(self) -> bool:
180
- """Check if the foreground window matches the submit allowlist."""
181
- if not self._submit_windows:
182
- return True
183
- info = _get_foreground_window_info()
184
- process_name = info.split(":")[0].strip() if ":" in info else ""
185
- return process_name in self._submit_windows
186
-
187
- def _update_typed_text(self, new_text: str) -> None:
188
- """Diff old typed text vs new at word level, minimising deletions.
189
-
190
- Only deletes back to the first word that changed; appends everything
191
- after the common word prefix. This avoids the jarring full-delete
192
- that character-level diffing causes when earlier words shift slightly.
193
- """
194
- old = self._typed_text
195
- if old == new_text:
196
- return
197
-
198
- old_words = old.split()
199
- new_words = new_text.split()
200
-
201
- # Find the longest common word prefix
202
- common_words = 0
203
- for a, b in zip(old_words, new_words):
204
- if a == b:
205
- common_words += 1
206
- else:
207
- break
208
-
209
- # Character position where the common word prefix ends (including
210
- # the trailing space after the last common word, if any).
211
- if common_words == 0:
212
- keep_chars = 0
213
- else:
214
- # Rejoin the common words and add one space (the separator before
215
- # the next word that was already typed).
216
- keep = " ".join(old_words[:common_words])
217
- # Only count the trailing space if there were more old words after
218
- # the common prefix (meaning that space is already on screen).
219
- if common_words < len(old_words):
220
- keep_chars = len(keep) + 1 # +1 for the space
221
- else:
222
- keep_chars = len(keep)
223
-
224
- to_delete = len(old) - keep_chars
225
- if to_delete > 0:
226
- keyboard.backspace(to_delete)
227
-
228
- # Build the new suffix to type from the first divergent word onward
229
- if common_words == 0:
230
- to_type = new_text
231
- else:
232
- suffix = " ".join(new_words[common_words:])
233
- if suffix:
234
- # Need a space separator if we kept text and are appending
235
- if keep_chars > 0 and common_words < len(old_words):
236
- to_type = suffix
237
- elif keep_chars > 0:
238
- to_type = " " + suffix
239
- else:
240
- to_type = suffix
241
- else:
242
- to_type = ""
243
-
244
- if to_type:
245
- keyboard.type_text(to_type)
246
- self._typed_text = new_text
247
-
248
- def _process_audio_chunk(
249
- self, chunk, prob: float, sample_count: int, trailing_silence: int
250
- ) -> tuple[int, int]:
251
- """Buffer audio chunk, run partial STT, and check for segment end."""
252
- self._audio_buffer.append(chunk)
253
- sample_count += len(chunk)
254
-
255
- if prob > self._vad.threshold:
256
- trailing_silence = 0
257
- else:
258
- trailing_silence += 1
259
-
260
- if sample_count - self._last_partial_at >= PARTIAL_STT_INTERVAL:
261
- self._last_partial_at = sample_count
262
- self._run_partial_stt()
263
-
264
- is_end, reset_silence = self._check_segment_end(sample_count, trailing_silence)
265
- if is_end:
266
- self._finalize_utterance()
267
- sample_count = 0
268
- trailing_silence = 0
269
- elif reset_silence:
270
- trailing_silence = 0
271
-
272
- return sample_count, trailing_silence
273
-
274
- def _check_segment_end(
275
- self, sample_count: int, trailing_silence: int
276
- ) -> tuple[bool, bool]:
277
- """Check if the current segment is done.
278
-
279
- Follows the reference smart-turn implementation:
280
- 1. Accumulate speech + trailing silence.
281
- 2. After STOP_MS of continuous silence, send the full segment to smart turn.
282
- 3. If smart turn says "Incomplete", keep listening.
283
- 4. If smart turn says "Complete", finalize.
284
- 5. Hard cap at MAX_SPEECH_SECONDS always finalizes.
285
-
286
- Returns (is_end, reset_silence). When reset_silence is True the caller
287
- must zero trailing_silence so that another full STOP_MS of silence is
288
- required before re-checking smart turn.
289
- """
290
- max_samples = MAX_SPEECH_SECONDS * 16000
291
-
292
- if sample_count >= max_samples:
293
- log("max_speech", "Reached max speech duration")
294
- return True, False
295
-
296
- if trailing_silence >= STOP_CHUNKS:
297
- audio_so_far = np.concatenate(self._audio_buffer)
298
- is_complete = self._smart_turn.is_end_of_turn(audio_so_far)
299
- if DEBUG:
300
- label = "Complete" if is_complete else "Incomplete"
301
- print(f"\n Smart turn: {label}", file=sys.stderr)
302
- if is_complete:
303
- return True, False
304
- else:
305
- log("smart_turn_incomplete", "Continuing to listen...")
306
- return False, True
307
- return False, False
308
-
309
- def _dispatch_result(self, text: str) -> None:
310
- """Log and optionally submit a recognized command."""
311
- should_submit = self._should_submit()
312
- if should_submit:
313
- log("dispatch_enter", text)
314
- if DEBUG:
315
- print(f" Final: {text} [Enter]", file=sys.stderr)
316
- keyboard.press_enter()
317
- else:
318
- log("dispatch_typed", text)
319
- if DEBUG:
320
- print(f" Final: {text} (no submit)", file=sys.stderr)
321
-
322
- def _finalize_utterance(self) -> None:
323
- """End of turn: final STT, correct typed text, press Enter."""
324
- if not self._audio_buffer:
325
- self._reset_listening()
326
- return
327
-
328
- audio = np.concatenate(self._audio_buffer)
329
- duration = len(audio) / 16000
330
- log("end_of_turn", f"audio_length={duration:.1f}s")
331
-
332
- if DEBUG:
333
- print(file=sys.stderr)
334
-
335
- text = self._stt.transcribe(audio)
336
-
337
- if self._state == ACTIVATED:
338
- self._finalize_activated_command(text)
339
- self._reset_listening()
340
- return
341
-
342
- if self._wake_detected:
343
- self._finalize_streamed_wake_word(text)
344
- elif not self._finalize_check_final_text(text):
345
- return # entered ACTIVATED state, don't reset to IDLE
346
-
347
- self._reset_listening()
348
-
349
- def _finalize_activated_command(self, text: str) -> None:
350
- """Finalize utterance in ACTIVATED state (full text is the command)."""
351
- command = text.strip()
352
- if command:
353
- if command != self._typed_text:
354
- if self._typed_text:
355
- self._update_typed_text(command)
356
- else:
357
- keyboard.type_text(command)
358
- self._dispatch_result(command)
359
- else:
360
- if self._typed_text:
361
- keyboard.backspace(len(self._typed_text))
362
- log("dispatch_cancelled", "Empty command in activated mode")
363
-
364
- def _finalize_streamed_wake_word(self, text: str) -> None:
365
- """Finalize when wake word was detected during streaming."""
366
- found, command = check_wake_word(text)
367
- if found and command:
368
- if command != self._typed_text:
369
- self._update_typed_text(command)
370
- self._dispatch_result(command)
371
- elif found:
372
- if self._typed_text:
373
- keyboard.backspace(len(self._typed_text))
374
- log("dispatch_cancelled", "No command after wake word")
375
- elif self._typed_text:
376
- # Final transcription lost the wake word (e.g. audio clipping
377
- # turned "computer" into "uter"); fall back to the command
378
- # captured during streaming
379
- self._dispatch_result(self._typed_text)
380
-
381
- def _finalize_check_final_text(self, text: str) -> bool:
382
- """Check final transcription for wake word.
383
-
384
- Returns True if caller should reset to IDLE, False if entering ACTIVATED.
385
- """
386
- found, command = check_wake_word(text)
387
- if found and command:
388
- log("wake_word_detected", command)
389
- if DEBUG:
390
- print(f" Wake word! Final: {command}", file=sys.stderr)
391
- keyboard.type_text(command)
392
- self._dispatch_result(command)
393
- if found and not command:
394
- # Wake word only — enter ACTIVATED state for next utterance
395
- log("wake_word_only", "Listening for command...")
396
- if DEBUG:
397
- print(" Wake word heard — listening for command...", file=sys.stderr)
398
- self._audio_buffer.clear()
399
- self._vad.reset()
400
- self._wake_detected = False
401
- self._typed_text = ""
402
- self._last_partial_at = 0
403
- self._activated_at = time.monotonic()
404
- self._state = ACTIVATED
405
- return False
406
- elif not found:
407
- log("no_wake_word", text)
408
- if DEBUG:
409
- print(f" No wake word: {text}", file=sys.stderr)
410
- return True
411
-
412
- def _reset_listening(self) -> None:
413
- self._audio_buffer.clear()
414
- self._vad.reset()
415
- self._wake_detected = False
416
- self._typed_text = ""
417
- self._last_partial_at = 0
418
- self._activated_at = 0.0
419
- self._state = IDLE
420
-
421
- def _check_activated_timeout(self) -> bool:
422
- """If in ACTIVATED state with no audio buffered, check for timeout.
423
-
424
- Returns True if timed out and state was reset.
425
- """
426
- if self._state != ACTIVATED or self._audio_buffer:
427
- return False
428
- if time.monotonic() - self._activated_at <= ACTIVATED_TIMEOUT:
429
- return False
430
- log("activated_timeout", "No command received")
431
- if DEBUG:
432
- print("\n Activation timed out", file=sys.stderr)
433
- self._reset_listening()
434
- return True
435
-
436
- def run(self) -> None:
437
- signal.signal(signal.SIGTERM, self._handle_signal)
438
- signal.signal(signal.SIGINT, self._handle_signal)
439
-
440
- log("daemon_start", "Starting audio capture...")
441
- self._mic.start()
442
-
443
- if DEBUG:
444
- print("Listening... (Ctrl+C to stop)", file=sys.stderr)
445
-
446
- sample_count = 0
447
- trailing_silence = 0
448
-
449
- try:
450
- while self._running:
451
- chunk = self._mic.read(timeout=0.5)
452
- if chunk is None:
453
- self._check_activated_timeout()
454
- continue
455
-
456
- prob = self._vad.process(chunk)
457
-
458
- if DEBUG:
459
- _print_state(self._state)
460
-
461
- if self._state == IDLE:
462
- if prob > self._vad.threshold:
463
- log("speech_start")
464
- self._state = LISTENING
465
- self._audio_buffer.append(chunk)
466
- sample_count = len(chunk)
467
- trailing_silence = 0
468
- self._last_partial_at = 0
469
-
470
- elif self._state == ACTIVATED:
471
- if self._check_activated_timeout():
472
- continue
473
-
474
- if prob > self._vad.threshold and not self._audio_buffer:
475
- log("speech_start", "command after activation")
476
-
477
- if prob > self._vad.threshold or self._audio_buffer:
478
- sample_count, trailing_silence = self._process_audio_chunk(
479
- chunk, prob, sample_count, trailing_silence
480
- )
481
-
482
- elif self._state == LISTENING:
483
- sample_count, trailing_silence = self._process_audio_chunk(
484
- chunk, prob, sample_count, trailing_silence
485
- )
486
-
487
- finally:
488
- if DEBUG:
489
- print(file=sys.stderr)
490
- self._mic.stop()
491
- log("daemon_stop", "Voice daemon stopped")
492
-
493
-
494
- def _setup_console() -> None:
495
- """On Windows, reopen stdout/stderr to the process's own console (CONOUT$).
496
-
497
- When launched as a detached background process, Node.js redirects stdio to
498
- NUL. Windows still allocates a console window for the process, but nothing
499
- appears in it. Opening CONOUT$ gives us a handle to that console so all
500
- debug output shows up there.
501
- """
502
- if sys.platform != "win32":
503
- return
504
- try:
505
- con = open("CONOUT$", "w", encoding="utf-8")
506
- sys.stdout = con
507
- sys.stderr = con
508
- except OSError:
509
- pass
510
-
511
-
512
- def main() -> None:
513
- _setup_console()
514
- log("daemon_launch", f"PID={os.getpid()}")
515
- try:
516
- daemon = VoiceDaemon()
517
- daemon.run()
518
- except Exception as e:
519
- log("daemon_crash", str(e), level="error")
520
- if DEBUG:
521
- import traceback
522
-
523
- traceback.print_exc(file=sys.stderr)
524
- sys.exit(1)
525
-
526
-
527
- if __name__ == "__main__":
528
- main()
@@ -1,26 +0,0 @@
1
- """Keyword detection in transcribed text."""
2
-
3
- import os
4
-
5
- from logger import log
6
-
7
- DEFAULT_WAKE_WORDS = ["computer"]
8
-
9
-
10
- def get_wake_words() -> list[str]:
11
- env = os.environ.get("VOICE_WAKE_WORDS", "")
12
- if env:
13
- return [w.strip().lower() for w in env.split(",") if w.strip()]
14
- return DEFAULT_WAKE_WORDS
15
-
16
-
17
- def check_wake_word(text: str) -> tuple[bool, str]:
18
- """Check if text contains a wake word. Returns (found, remaining_text)."""
19
- lower = text.lower()
20
- for word in get_wake_words():
21
- idx = lower.find(word)
22
- if idx != -1:
23
- remaining = text[idx + len(word) :].strip().lstrip(",").strip()
24
- log("wake_word_detected", word, remaining=remaining)
25
- return True, remaining
26
- return False, text