friend-assistant 0.1.41 → 0.1.42

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.
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.41",
4
- "builtAt": "2026-10-13T04:06:44.122Z"
3
+ "version": "0.1.42",
4
+ "builtAt": "2026-10-13T05:30:33.387Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -159,6 +159,14 @@ function resolveVoice(input) {
159
159
  if (PLAIN.test(t) && PIPER_NAME.test(t)) return { id: t, label: t, listed: false };
160
160
  return null;
161
161
  }
162
+ var LISTENING_MODELS = [
163
+ { id: "tiny", name: "Tiny", mb: 75, note: "fastest, mishears a lot" },
164
+ { id: "base", name: "Base", mb: 145, note: "fast, struggles with Hindi, Marathi and distance" },
165
+ { id: "small", name: "Small", mb: 480, note: "recommended: much better, still quick" },
166
+ { id: "medium", name: "Medium", mb: 1500, note: "better again, slow on a PC without a graphics card" },
167
+ { id: "large-v3-turbo", name: "Large (turbo)", mb: 1600, note: "most accurate for Marathi; needs a fast PC" }
168
+ ];
169
+ var downloadSize = (mb) => `about ${mb >= 1e3 ? `${(mb / 1e3).toFixed(1)} GB` : `${mb} MB`}`;
162
170
 
163
171
  // ../protocol/src/themes.ts
164
172
  var THEMES = [
@@ -12970,7 +12978,7 @@ function refuseForAgent(raw) {
12970
12978
  if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
12971
12979
  }
12972
12980
  var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
12973
- var RESTART_VOICE = ["enabled", "sttModel"];
12981
+ var RESTART_VOICE = ["enabled"];
12974
12982
  function applySettings(config, raw) {
12975
12983
  const parsed = SettingsPatch.safeParse(raw);
12976
12984
  if (!parsed.success) {
@@ -13852,7 +13860,11 @@ var HttpVoiceService = class {
13852
13860
  });
13853
13861
  return Buffer.from(await res.arrayBuffer());
13854
13862
  }
13855
- async request(path3, init) {
13863
+ /** Loading a model the first time downloads it (up to about 1.6 GB), so this waits far longer than a normal request. */
13864
+ async useListeningModel(name) {
13865
+ await this.request("/stt-model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: name }) }, 30 * 6e4);
13866
+ }
13867
+ async request(path3, init, timeoutMs) {
13856
13868
  const base = this.opts.baseUrl();
13857
13869
  if (!base) throw new VoiceError("the voice sidecar is not running");
13858
13870
  let res;
@@ -13860,7 +13872,7 @@ var HttpVoiceService = class {
13860
13872
  res = await fetch(base + path3, {
13861
13873
  ...init,
13862
13874
  headers: { ...init.headers, authorization: `Bearer ${this.opts.token}` },
13863
- signal: AbortSignal.timeout(this.opts.timeoutMs ?? 6e4)
13875
+ signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? 6e4)
13864
13876
  });
13865
13877
  } catch (e) {
13866
13878
  if (e.name === "TimeoutError") throw new VoiceError("the voice sidecar timed out");
@@ -14379,6 +14391,7 @@ async function createApp(opts) {
14379
14391
  function commitSettings(patch, how) {
14380
14392
  let applied;
14381
14393
  let voiceBefore = config.voice.ttsVoice;
14394
+ let sttBefore = config.voice.sttModel;
14382
14395
  try {
14383
14396
  const saved = loadConfig(opts.dataDir);
14384
14397
  const check3 = applySettings(saved, patch);
@@ -14386,6 +14399,7 @@ async function createApp(opts) {
14386
14399
  throw new HttpError(409, "Friend is busy with a request. Change the model when it is done.");
14387
14400
  }
14388
14401
  voiceBefore = config.voice.ttsVoice;
14402
+ sttBefore = config.voice.sttModel;
14389
14403
  applied = applySettings(config, patch);
14390
14404
  saveConfig(saved);
14391
14405
  } catch (e) {
@@ -14411,6 +14425,7 @@ async function createApp(opts) {
14411
14425
  else swap();
14412
14426
  }
14413
14427
  if (applied.changed.includes("voice.ttsVoice")) void warmVoice(config.voice.ttsVoice, voiceBefore);
14428
+ if (applied.changed.includes("voice.sttModel")) void warmListening(config.voice.sttModel, sttBefore);
14414
14429
  if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
14415
14430
  if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
14416
14431
  if (applied.changed.some((k) => k === "view" || k === "ownerName" || k.startsWith("look.") || k.startsWith("hud") || k.startsWith("music"))) {
@@ -14437,6 +14452,25 @@ async function createApp(opts) {
14437
14452
  broadcast({ type: "alert", level: "warn", text: `Could not load the voice ${wanted} (it needs internet the first time). Using ${voiceLabel(previous)} again.` });
14438
14453
  }
14439
14454
  }
14455
+ async function warmListening(wanted, previous) {
14456
+ if (!voice2?.useListeningModel || !voiceAvailable()) return;
14457
+ const known = LISTENING_MODELS.find((m) => m.id === wanted);
14458
+ const download = known ? ` (${downloadSize(known.mb)} the first time it is downloaded)` : ". The first time it downloads";
14459
+ broadcast({ type: "alert", level: "info", text: `Getting the listening model ${wanted}${download}. Friend keeps listening with the old one meanwhile.` });
14460
+ try {
14461
+ await voice2.useListeningModel(wanted);
14462
+ broadcast({ type: "alert", level: "info", text: `The listening model ${wanted} is ready.` });
14463
+ } catch (e) {
14464
+ audit.log("system", "voice.model_failed", { model: wanted, message: e.message });
14465
+ if (config.voice.sttModel === wanted) {
14466
+ config.voice.sttModel = previous;
14467
+ const saved = loadConfig(opts.dataDir);
14468
+ saved.voice.sttModel = previous;
14469
+ saveConfig(saved);
14470
+ }
14471
+ broadcast({ type: "alert", level: "warn", text: `Could not load the listening model ${wanted} (it needs internet the first time). Using ${previous} again.` });
14472
+ }
14473
+ }
14440
14474
  async function warmLanguageVoice(wanted) {
14441
14475
  if (!wanted || !voice2 || !voiceAvailable()) return;
14442
14476
  broadcast({ type: "alert", level: "info", text: `Loading the voice ${voiceLabel(wanted)}. The first time it downloads about 64 MB.` });
@@ -17844,7 +17878,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
17844
17878
  import crossSpawn5 from "cross-spawn";
17845
17879
  var PACKAGE = "friend-assistant";
17846
17880
  var real = {
17847
- current: () => true ? "0.1.41" : "development",
17881
+ current: () => true ? "0.1.42" : "development",
17848
17882
  latest: () => new Promise((resolve13) => {
17849
17883
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
17850
17884
  let out = "";
@@ -18132,7 +18166,7 @@ async function run(argv, io2) {
18132
18166
  case "version":
18133
18167
  case "--version":
18134
18168
  case "-v":
18135
- io2.out(true ? "0.1.41" : "development");
18169
+ io2.out(true ? "0.1.42" : "development");
18136
18170
  return 0;
18137
18171
  case "lan":
18138
18172
  return await lanCommand(rest, io2);
@@ -32,7 +32,13 @@ def main() -> int:
32
32
 
33
33
  engines = load_engines(args.data_dir, args.stt_model, args.tts_voice)
34
34
 
35
- server, port = make_server(engines, token, port=args.port)
35
+ if args.fake:
36
+ loader = lambda name: FakeEngines().stt # noqa: E731
37
+ else:
38
+ from .models import load_stt
39
+
40
+ loader = lambda name: load_stt(args.data_dir, name) # noqa: E731
41
+ server, port = make_server(engines, token, port=args.port, load_stt=loader)
36
42
  print(json.dumps({"ready": True, "port": port}), flush=True)
37
43
 
38
44
  # When the parent process goes away its end of our stdin closes: shut down instead of lingering.
@@ -46,6 +46,12 @@ def ensure_speaker_model(directory: Path, url: str = SPEAKER_URL, sha256: str =
46
46
  return target
47
47
 
48
48
 
49
+ def load_stt(data_dir: Optional[str], name: str) -> WhisperSTT:
50
+ """One listening model, downloaded into the models folder when it is not there yet."""
51
+ root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
52
+ return WhisperSTT(name, str(root / "whisper"))
53
+
54
+
49
55
  def load_engines(data_dir: Optional[str], stt_model: str = "small", tts_voice: str = "en_US-lessac-medium") -> Engines:
50
56
  root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
51
57
  root.mkdir(parents=True, exist_ok=True)
@@ -4,20 +4,33 @@ from __future__ import annotations
4
4
 
5
5
  import hmac
6
6
  import json
7
+ import re
8
+ import threading
7
9
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
- from typing import Tuple
10
+ from typing import Callable, Optional, Tuple
9
11
  from urllib.parse import parse_qs, urlparse
10
12
 
11
13
  from .engines import Engines
12
14
  from .tts import check_voice_name
13
15
 
14
16
  MAX_TTS_CHARS = 2000
17
+ #: What a listening model is called: tiny, base, small, large-v3-turbo, or a Hugging Face folder such as Systran/faster-whisper-small.
18
+ MODEL_NAME = re.compile(r"^[A-Za-z0-9_./-]{1,80}$")
15
19
 
16
20
 
17
21
  def make_server(
18
- engines: Engines, token: str, port: int = 0, max_body: int = 4_000_000, io_timeout: float = 30
22
+ engines: Engines,
23
+ token: str,
24
+ port: int = 0,
25
+ max_body: int = 4_000_000,
26
+ io_timeout: float = 30,
27
+ load_stt: Optional[Callable[[str], object]] = None,
19
28
  ) -> Tuple[ThreadingHTTPServer, int]:
20
- """Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port)."""
29
+ """Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port).
30
+
31
+ `load_stt(name)` builds (and, the first time, downloads) a listening model; POST /stt-model swaps it in while running.
32
+ """
33
+ swapping = threading.Lock() # one model change at a time
21
34
 
22
35
  class Handler(BaseHTTPRequestHandler):
23
36
  protocol_version = "HTTP/1.1"
@@ -85,6 +98,8 @@ def make_server(
85
98
  return self._tts()
86
99
  if url.path == "/embed":
87
100
  return self._embed()
101
+ if url.path == "/stt-model":
102
+ return self._stt_model()
88
103
  self._drain(64 * 1024)
89
104
  self._json(404, {"error": "not found"}, close=True)
90
105
 
@@ -118,6 +133,26 @@ def make_server(
118
133
  except Exception as e: # engine failures are reported, never crash the server
119
134
  self._json(500, {"error": str(e)})
120
135
 
136
+ def _stt_model(self) -> None:
137
+ raw = self._read_body()
138
+ if raw is None:
139
+ return
140
+ if load_stt is None:
141
+ return self._json(501, {"error": "this speech engine cannot change its listening model"})
142
+ try:
143
+ name = json.loads(raw)["model"]
144
+ except (ValueError, KeyError, TypeError):
145
+ return self._json(400, {"error": "expected JSON with a model field"})
146
+ if not isinstance(name, str) or not MODEL_NAME.match(name) or ".." in name:
147
+ return self._json(400, {"error": "that is not a listening model name"})
148
+ with swapping:
149
+ try:
150
+ fresh = load_stt(name) # may take minutes the first time (download); the old model keeps listening meanwhile
151
+ except Exception as e:
152
+ return self._json(500, {"error": str(e) or "the listening model could not be loaded"})
153
+ engines.stt = fresh
154
+ self._json(200, {"ok": True, "model": name})
155
+
121
156
  def _embed(self) -> None:
122
157
  audio = self._read_body()
123
158
  if audio is None: