@staff0rd/assist 0.83.0 → 0.84.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.
@@ -15,20 +15,3 @@ dev = [
15
15
  "ruff>=0.8",
16
16
  ]
17
17
 
18
- [tool.setuptools]
19
- py-modules = [
20
- "audio_capture",
21
- "dispatch",
22
- "logger",
23
- "smart_turn",
24
- "stt",
25
- "vad",
26
- "voice_daemon",
27
- "wake_word",
28
- "setup_models",
29
- "list_devices",
30
- ]
31
-
32
- [build-system]
33
- requires = ["setuptools>=68"]
34
- build-backend = "setuptools.build_meta"
@@ -239,7 +239,7 @@ wheels = [
239
239
  [[package]]
240
240
  name = "assist-voice"
241
241
  version = "0.1.0"
242
- source = { editable = "." }
242
+ source = { virtual = "." }
243
243
  dependencies = [
244
244
  { name = "nemo-toolkit", extra = ["asr"] },
245
245
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -3118,7 +3118,7 @@ name = "nvidia-cudnn-cu12"
3118
3118
  version = "9.10.2.21"
3119
3119
  source = { registry = "https://pypi.org/simple" }
3120
3120
  dependencies = [
3121
- { name = "nvidia-cublas-cu12" },
3121
+ { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
3122
3122
  ]
3123
3123
  wheels = [
3124
3124
  { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
@@ -3129,7 +3129,7 @@ name = "nvidia-cufft-cu12"
3129
3129
  version = "11.3.3.83"
3130
3130
  source = { registry = "https://pypi.org/simple" }
3131
3131
  dependencies = [
3132
- { name = "nvidia-nvjitlink-cu12" },
3132
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
3133
3133
  ]
3134
3134
  wheels = [
3135
3135
  { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
@@ -3156,9 +3156,9 @@ name = "nvidia-cusolver-cu12"
3156
3156
  version = "11.7.3.90"
3157
3157
  source = { registry = "https://pypi.org/simple" }
3158
3158
  dependencies = [
3159
- { name = "nvidia-cublas-cu12" },
3160
- { name = "nvidia-cusparse-cu12" },
3161
- { name = "nvidia-nvjitlink-cu12" },
3159
+ { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" },
3160
+ { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux'" },
3161
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
3162
3162
  ]
3163
3163
  wheels = [
3164
3164
  { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
@@ -3169,7 +3169,7 @@ name = "nvidia-cusparse-cu12"
3169
3169
  version = "12.5.8.93"
3170
3170
  source = { registry = "https://pypi.org/simple" }
3171
3171
  dependencies = [
3172
- { name = "nvidia-nvjitlink-cu12" },
3172
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" },
3173
3173
  ]
3174
3174
  wheels = [
3175
3175
  { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
@@ -1,7 +1,10 @@
1
1
  """Voice daemon entry point — main loop and signal handling."""
2
2
 
3
+ import ctypes
4
+ import json
3
5
  import os
4
6
  import signal
7
+ import subprocess
5
8
  import sys
6
9
  import time
7
10
 
@@ -16,6 +19,73 @@ from wake_word import check_wake_word
16
19
 
17
20
  import keyboard
18
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
+
19
89
  # States
20
90
  IDLE = "idle"
21
91
  LISTENING = "listening"
@@ -36,23 +106,9 @@ STOP_CHUNKS = (STOP_MS * 16000) // (BLOCK_SIZE * 1000) # ~31 chunks
36
106
  ACTIVATED_TIMEOUT = 10.0
37
107
 
38
108
 
39
- def _print_vad_bar(
40
- prob: float, threshold: float, state: str, chunk: np.ndarray
41
- ) -> None:
42
- """Print a live VAD meter to stderr when debug mode is on."""
43
- rms = float(np.sqrt(np.mean(chunk**2)))
44
- peak = float(np.max(np.abs(chunk)))
45
- width = 40
46
- filled = int(prob * width)
47
- bar = "█" * filled + "░" * (width - filled)
48
- marker = ">" if prob > threshold else " "
49
- print(
50
- f"\r {marker} VAD {prob:.2f} [{bar}] "
51
- f"rms={rms:.4f} peak={peak:.4f} {state:10s}",
52
- end="",
53
- file=sys.stderr,
54
- flush=True,
55
- )
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)
56
112
 
57
113
 
58
114
  class VoiceDaemon:
@@ -60,15 +116,17 @@ class VoiceDaemon:
60
116
  self._running = True
61
117
  self._state = IDLE
62
118
  self._audio_buffer: list[np.ndarray] = []
63
- self._submit_word = os.environ.get("VOICE_SUBMIT_WORD", "").strip().lower()
119
+
120
+ config = _load_voice_config()
121
+ self._submit_windows: set[str] = set(config.get("submitWindows") or [])
64
122
 
65
123
  log("daemon_init", "Initializing models...")
66
124
  self._mic = AudioCapture()
67
125
  self._vad = SileroVAD()
68
126
  self._smart_turn = SmartTurn()
69
127
  self._stt = ParakeetSTT()
70
- if self._submit_word:
71
- log("daemon_init", f"Submit word: '{self._submit_word}'")
128
+ if self._submit_windows:
129
+ log("daemon_init", f"Submit windows: {self._submit_windows}")
72
130
  log("daemon_ready")
73
131
 
74
132
  # Incremental typing state
@@ -96,7 +154,7 @@ class VoiceDaemon:
96
154
 
97
155
  if self._state == ACTIVATED:
98
156
  # Already activated — everything is the command, no wake word needed
99
- partial = self._hide_submit_word(text.strip())
157
+ partial = text.strip()
100
158
  if partial and partial != self._typed_text:
101
159
  if self._typed_text:
102
160
  self._update_typed_text(partial)
@@ -106,41 +164,25 @@ class VoiceDaemon:
106
164
  elif not self._wake_detected:
107
165
  found, command = check_wake_word(text)
108
166
  if found and command:
109
- partial = self._hide_submit_word(command)
110
- if partial:
111
- self._wake_detected = True
112
- log("wake_word_detected", partial)
113
- if DEBUG:
114
- print(f" Wake word! Typing: {partial}", file=sys.stderr)
115
- keyboard.type_text(partial)
116
- self._typed_text = partial
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
117
173
  else:
118
174
  found, command = check_wake_word(text)
119
175
  if found and command:
120
- partial = self._hide_submit_word(command)
121
- if partial and partial != self._typed_text:
122
- self._update_typed_text(partial)
176
+ if command != self._typed_text:
177
+ self._update_typed_text(command)
123
178
 
124
- def _strip_submit_word(self, text: str) -> tuple[bool, str]:
125
- """Check if text ends with the submit word.
126
-
127
- Returns (should_submit, stripped_text).
128
- If no submit word is configured, always returns (True, text).
129
- """
130
- if not self._submit_word:
131
- return True, text
132
- words = text.rsplit(None, 1)
133
- if len(words) >= 1 and words[-1].lower().rstrip(".,!?") == self._submit_word:
134
- stripped = text[: text.lower().rfind(words[-1].lower())].rstrip()
135
- return True, stripped
136
- return False, text
137
-
138
- def _hide_submit_word(self, text: str) -> str:
139
- """Strip trailing submit word from partial text so it's never typed."""
140
- if not self._submit_word:
141
- return text
142
- _, stripped = self._strip_submit_word(text)
143
- return stripped
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
144
186
 
145
187
  def _update_typed_text(self, new_text: str) -> None:
146
188
  """Diff old typed text vs new, backspace + type the difference."""
@@ -212,26 +254,18 @@ class VoiceDaemon:
212
254
  log("smart_turn_incomplete", "Continuing to listen...")
213
255
  return False
214
256
 
215
- def _dispatch_result(self, should_submit: bool, stripped: str) -> None:
257
+ def _dispatch_result(self, text: str) -> None:
216
258
  """Log and optionally submit a recognized command."""
217
- if stripped:
218
- if should_submit:
219
- log("dispatch_enter", stripped)
220
- if DEBUG:
221
- print(f" Final: {stripped} [Enter]", file=sys.stderr)
222
- keyboard.press_enter()
223
- else:
224
- log("dispatch_typed", stripped)
225
- if DEBUG:
226
- print(f" Final: {stripped} (no submit)", file=sys.stderr)
227
- elif should_submit:
228
- # Submit word only — erase it and press enter
229
- if self._typed_text:
230
- keyboard.backspace(len(self._typed_text))
231
- log("dispatch_enter", "(submit word only)")
259
+ should_submit = self._should_submit()
260
+ if should_submit:
261
+ log("dispatch_enter", text)
232
262
  if DEBUG:
233
- print(" Submit word only [Enter]", file=sys.stderr)
263
+ print(f" Final: {text} [Enter]", file=sys.stderr)
234
264
  keyboard.press_enter()
265
+ else:
266
+ log("dispatch_typed", text)
267
+ if DEBUG:
268
+ print(f" Final: {text} (no submit)", file=sys.stderr)
235
269
 
236
270
  def _finalize_utterance(self) -> None:
237
271
  """End of turn: final STT, correct typed text, press Enter."""
@@ -252,14 +286,12 @@ class VoiceDaemon:
252
286
  # Activated mode — full text is the command
253
287
  command = text.strip()
254
288
  if command:
255
- should_submit, stripped = self._strip_submit_word(command)
256
- if stripped:
257
- if stripped != self._typed_text:
258
- if self._typed_text:
259
- self._update_typed_text(stripped)
260
- else:
261
- keyboard.type_text(stripped)
262
- self._dispatch_result(should_submit, stripped)
289
+ if command != self._typed_text:
290
+ if self._typed_text:
291
+ self._update_typed_text(command)
292
+ else:
293
+ keyboard.type_text(command)
294
+ self._dispatch_result(command)
263
295
  else:
264
296
  if self._typed_text:
265
297
  keyboard.backspace(len(self._typed_text))
@@ -271,11 +303,9 @@ class VoiceDaemon:
271
303
  # Correct final text and submit
272
304
  found, command = check_wake_word(text)
273
305
  if found and command:
274
- should_submit, stripped = self._strip_submit_word(command)
275
- if stripped:
276
- if stripped != self._typed_text:
277
- self._update_typed_text(stripped)
278
- self._dispatch_result(should_submit, stripped)
306
+ if command != self._typed_text:
307
+ self._update_typed_text(command)
308
+ self._dispatch_result(command)
279
309
  elif found:
280
310
  # Wake word found but no command text after it
281
311
  if self._typed_text:
@@ -285,29 +315,16 @@ class VoiceDaemon:
285
315
  # Final transcription lost the wake word (e.g. audio clipping
286
316
  # turned "computer" into "uter"); fall back to the command
287
317
  # captured during streaming
288
- should_submit, stripped = self._strip_submit_word(self._typed_text)
289
- self._dispatch_result(should_submit, stripped or self._typed_text)
318
+ self._dispatch_result(self._typed_text)
290
319
  else:
291
320
  # Check final transcription for wake word
292
321
  found, command = check_wake_word(text)
293
322
  if found and command:
294
- should_submit, stripped = self._strip_submit_word(command)
295
- if stripped:
296
- log("wake_word_detected", stripped)
297
- if DEBUG:
298
- label = "[Enter]" if should_submit else "(no submit)"
299
- print(
300
- f" Wake word! Final: {stripped} {label}", file=sys.stderr
301
- )
302
- keyboard.type_text(stripped)
303
- if should_submit:
304
- keyboard.press_enter()
305
- else:
306
- # Submit word only — just press enter
307
- log("dispatch_enter", "(submit word only)")
308
- if DEBUG:
309
- print(" Wake word + submit word only [Enter]", file=sys.stderr)
310
- keyboard.press_enter()
323
+ log("wake_word_detected", command)
324
+ if DEBUG:
325
+ print(f" Wake word! Final: {command}", file=sys.stderr)
326
+ keyboard.type_text(command)
327
+ self._dispatch_result(command)
311
328
  if found and not command:
312
329
  # Wake word only — enter ACTIVATED state for next utterance
313
330
  log("wake_word_only", "Listening for command...")
@@ -367,7 +384,7 @@ class VoiceDaemon:
367
384
  prob = self._vad.process(chunk)
368
385
 
369
386
  if DEBUG:
370
- _print_vad_bar(prob, self._vad.threshold, self._state, chunk)
387
+ _print_state(self._state)
371
388
 
372
389
  if self._state == IDLE:
373
390
  if prob > self._vad.threshold:
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { Command } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "@staff0rd/assist",
9
- version: "0.83.0",
9
+ version: "0.84.0",
10
10
  type: "module",
11
11
  main: "dist/index.js",
12
12
  bin: {
@@ -84,6 +84,7 @@ var runConfigSchema = z.strictObject({
84
84
  name: z.string(),
85
85
  command: z.string(),
86
86
  args: z.array(z.string()).optional(),
87
+ env: z.record(z.string(), z.string()).optional(),
87
88
  filter: z.string().optional()
88
89
  });
89
90
  var transcriptConfigSchema = z.strictObject({
@@ -91,6 +92,8 @@ var transcriptConfigSchema = z.strictObject({
91
92
  transcriptsDir: z.string(),
92
93
  summaryDir: z.string()
93
94
  });
95
+ var DEFAULT_WAKE_WORDS = ["computer"];
96
+ var DEFAULT_MODELS_DIR = "~/.assist/voice/models";
94
97
  var assistConfigSchema = z.strictObject({
95
98
  commit: z.strictObject({
96
99
  conventional: z.boolean().default(false),
@@ -126,18 +129,21 @@ var assistConfigSchema = z.strictObject({
126
129
  run: z.array(runConfigSchema).optional(),
127
130
  transcript: transcriptConfigSchema.optional(),
128
131
  voice: z.strictObject({
129
- wakeWords: z.array(z.string()).default(["computer"]),
132
+ wakeWords: z.array(z.string()).default(DEFAULT_WAKE_WORDS),
130
133
  mic: z.string().optional(),
131
134
  cwd: z.string().optional(),
132
- modelsDir: z.string().optional(),
135
+ modelsDir: z.string().default(DEFAULT_MODELS_DIR),
133
136
  lockDir: z.string().optional(),
134
- submitWord: z.string().optional(),
137
+ submitWindows: z.array(z.string()).optional(),
135
138
  models: z.strictObject({
136
139
  vad: z.string().optional(),
137
- smartTurn: z.string().optional(),
138
- stt: z.string().optional()
139
- }).optional()
140
- }).optional()
140
+ smartTurn: z.string().optional()
141
+ }).default({})
142
+ }).default({
143
+ wakeWords: DEFAULT_WAKE_WORDS,
144
+ modelsDir: DEFAULT_MODELS_DIR,
145
+ models: {}
146
+ })
141
147
  });
142
148
 
143
149
  // src/shared/loadConfig.ts
@@ -1403,6 +1409,7 @@ function getRunEntries() {
1403
1409
  return run3.filter((r) => r.name.startsWith("verify:")).map((r) => ({
1404
1410
  name: r.name,
1405
1411
  fullCommand: buildFullCommand(r.command, r.args),
1412
+ env: r.env,
1406
1413
  filter: r.filter
1407
1414
  }));
1408
1415
  }
@@ -1502,12 +1509,26 @@ function filterByChangedFiles(entries) {
1502
1509
 
1503
1510
  // src/commands/verify/run/spawnCommand.ts
1504
1511
  import { spawn } from "child_process";
1512
+
1513
+ // src/shared/expandEnv.ts
1514
+ import { homedir as homedir2 } from "os";
1515
+ function expandTilde(value) {
1516
+ return value.startsWith("~/") ? homedir2() + value.slice(1) : value;
1517
+ }
1518
+ function expandEnv(env) {
1519
+ return Object.fromEntries(
1520
+ Object.entries(env).map(([k, v]) => [k, expandTilde(v)])
1521
+ );
1522
+ }
1523
+
1524
+ // src/commands/verify/run/spawnCommand.ts
1505
1525
  var isClaudeCode = !!process.env.CLAUDECODE;
1506
- function spawnCommand(fullCommand, cwd) {
1526
+ function spawnCommand(fullCommand, cwd, env) {
1507
1527
  return spawn(fullCommand, [], {
1508
1528
  stdio: isClaudeCode ? "pipe" : "inherit",
1509
1529
  shell: true,
1510
- cwd: cwd ?? process.cwd()
1530
+ cwd: cwd ?? process.cwd(),
1531
+ env: env ? { ...process.env, ...expandEnv(env) } : void 0
1511
1532
  });
1512
1533
  }
1513
1534
  function collectOutput(child) {
@@ -1526,7 +1547,7 @@ function flushIfFailed(exitCode, chunks) {
1526
1547
  // src/commands/verify/run/index.ts
1527
1548
  function runEntry(entry, onComplete) {
1528
1549
  return new Promise((resolve3) => {
1529
- const child = spawnCommand(entry.fullCommand, entry.cwd);
1550
+ const child = spawnCommand(entry.fullCommand, entry.cwd, entry.env);
1530
1551
  const chunks = collectOutput(child);
1531
1552
  child.on("close", (code) => {
1532
1553
  const exitCode = code ?? 1;
@@ -2952,9 +2973,9 @@ import chalk37 from "chalk";
2952
2973
 
2953
2974
  // src/commands/devlog/loadDevlogEntries.ts
2954
2975
  import { readdirSync, readFileSync as readFileSync13 } from "fs";
2955
- import { homedir as homedir2 } from "os";
2976
+ import { homedir as homedir3 } from "os";
2956
2977
  import { join as join11 } from "path";
2957
- var DEVLOG_DIR = join11(homedir2(), "git/blog/src/content/devlog");
2978
+ var DEVLOG_DIR = join11(homedir3(), "git/blog/src/content/devlog");
2958
2979
  function loadDevlogEntries(repoName) {
2959
2980
  const entries = /* @__PURE__ */ new Map();
2960
2981
  try {
@@ -5306,11 +5327,11 @@ import { spawnSync as spawnSync3 } from "child_process";
5306
5327
  import { join as join24 } from "path";
5307
5328
 
5308
5329
  // src/commands/voice/shared.ts
5309
- import { homedir as homedir3 } from "os";
5330
+ import { homedir as homedir4 } from "os";
5310
5331
  import { dirname as dirname17, join as join23 } from "path";
5311
5332
  import { fileURLToPath as fileURLToPath4 } from "url";
5312
5333
  var __dirname5 = dirname17(fileURLToPath4(import.meta.url));
5313
- var VOICE_DIR = join23(homedir3(), ".assist", "voice");
5334
+ var VOICE_DIR = join23(homedir4(), ".assist", "voice");
5314
5335
  var voicePaths = {
5315
5336
  dir: VOICE_DIR,
5316
5337
  pid: join23(VOICE_DIR, "voice.pid"),
@@ -5399,14 +5420,12 @@ function checkLockFile() {
5399
5420
  }
5400
5421
  function bootstrapVenv() {
5401
5422
  if (existsSync24(getVenvPython())) return;
5402
- console.log("Creating Python virtual environment...");
5403
- execSync24(`uv venv "${voicePaths.venv}"`, { stdio: "inherit" });
5404
- console.log("Installing dependencies...");
5423
+ console.log("Setting up Python environment...");
5405
5424
  const pythonDir = getPythonDir();
5406
- execSync24(
5407
- `uv pip install --python "${getVenvPython()}" -e "${pythonDir}[dev]"`,
5408
- { stdio: "inherit" }
5409
- );
5425
+ execSync24(`uv sync --project "${pythonDir}" --no-install-project`, {
5426
+ stdio: "inherit",
5427
+ env: { ...process.env, UV_PROJECT_ENVIRONMENT: voicePaths.venv }
5428
+ });
5410
5429
  }
5411
5430
  function writeLockFile(pid) {
5412
5431
  const lockFile = getLockFile();
@@ -5443,26 +5462,8 @@ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync19 } from "fs";
5443
5462
  import { join as join27 } from "path";
5444
5463
 
5445
5464
  // src/commands/voice/buildDaemonEnv.ts
5446
- var ENV_MAP = {
5447
- VOICE_WAKE_WORDS: (v) => v.wakeWords?.join(","),
5448
- VOICE_MIC: (v) => v.mic,
5449
- VOICE_CWD: (v) => v.cwd,
5450
- VOICE_MODELS_DIR: (v) => v.modelsDir,
5451
- VOICE_MODEL_VAD: (v) => v.models?.vad,
5452
- VOICE_MODEL_SMART_TURN: (v) => v.models?.smartTurn,
5453
- VOICE_MODEL_STT: (v) => v.models?.stt,
5454
- VOICE_SUBMIT_WORD: (v) => v.submitWord
5455
- };
5456
5465
  function buildDaemonEnv(options2) {
5457
- const config = loadConfig();
5458
5466
  const env = { ...process.env };
5459
- const voice = config.voice;
5460
- if (voice) {
5461
- for (const [key, getter] of Object.entries(ENV_MAP)) {
5462
- const value = getter(voice);
5463
- if (value) env[key] = value;
5464
- }
5465
- }
5466
5467
  env.VOICE_LOG_FILE = voicePaths.log;
5467
5468
  if (options2?.debug) env.VOICE_DEBUG = "1";
5468
5469
  return env;
@@ -5905,8 +5906,12 @@ function onSpawnError(err) {
5905
5906
  console.error(`Failed to execute command: ${err.message}`);
5906
5907
  process.exit(1);
5907
5908
  }
5908
- function spawnCommand2(fullCommand) {
5909
- const child = spawn5(fullCommand, [], { stdio: "inherit", shell: true });
5909
+ function spawnCommand2(fullCommand, env) {
5910
+ const child = spawn5(fullCommand, [], {
5911
+ stdio: "inherit",
5912
+ shell: true,
5913
+ env: env ? { ...process.env, ...expandEnv(env) } : void 0
5914
+ });
5910
5915
  child.on("close", (code) => process.exit(code ?? 0));
5911
5916
  child.on("error", onSpawnError);
5912
5917
  }
@@ -5919,7 +5924,10 @@ function listRunConfigs() {
5919
5924
  }
5920
5925
  function run2(name, args) {
5921
5926
  const runConfig = findRunConfig(name);
5922
- spawnCommand2(buildCommand(runConfig.command, runConfig.args ?? [], args));
5927
+ spawnCommand2(
5928
+ buildCommand(runConfig.command, runConfig.args ?? [], args),
5929
+ runConfig.env
5930
+ );
5923
5931
  }
5924
5932
 
5925
5933
  // src/commands/statusLine.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staff0rd/assist",
3
- "version": "0.83.0",
3
+ "version": "0.84.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {