friend-assistant 0.1.43 → 0.1.45

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/friend.mjs CHANGED
@@ -312,6 +312,8 @@ var ServerMsg = z3.discriminatedUnion("type", [
312
312
  data: z3.string()
313
313
  }),
314
314
  z3.object({ type: z3.literal("audio.stop") }),
315
+ /** How loud music and video should be while the owner talks or Friend speaks: 1 is as it is, 0.25 a quarter. */
316
+ z3.object({ type: z3.literal("audio.duck"), level: z3.number().min(0).max(1) }),
315
317
  /** The owner approved a look through the camera: take one picture and upload it. */
316
318
  z3.object({ type: z3.literal("camera.request"), id: z3.string() }),
317
319
  /** Stop: the request was answered elsewhere, timed out, or the emergency lock fired. */
@@ -335,6 +337,8 @@ var ClientMsg = z3.discriminatedUnion("type", [
335
337
  z3.object({ type: z3.literal("stage.event"), objectId: z3.string(), event: z3.string().max(64) }),
336
338
  ApprovalRespond,
337
339
  z3.object({ type: z3.literal("voice.interrupt") }),
340
+ /** This screen is listening to the owner, or Friend is speaking on it (true), or neither (false). */
341
+ z3.object({ type: z3.literal("audio.activity"), active: z3.boolean() }),
338
342
  z3.object({ type: z3.literal("voice.confirm"), id: z3.string(), pin: z3.string().max(32) }),
339
343
  z3.object({ type: z3.literal("voice.cancel"), id: z3.string() }),
340
344
  z3.object({ type: z3.literal("camera.denied"), id: z3.string(), reason: z3.enum(["off", "no-camera", "permission", "busy"]) }),
@@ -3343,6 +3347,7 @@ function sanitizeLook(raw) {
3343
3347
  };
3344
3348
  }
3345
3349
  var DEFAULT_OLLAMA = { url: "http://127.0.0.1:11434", model: "qwen2.5:7b", contextTokens: 8192, vision: false };
3350
+ var sanitizeUpdates = (u) => ({ auto: typeof u?.auto === "boolean" ? u.auto : true });
3346
3351
  var DEFAULT_VOICE = {
3347
3352
  enabled: true,
3348
3353
  sttModel: "small",
@@ -3352,12 +3357,16 @@ var DEFAULT_VOICE = {
3352
3357
  accept: 0.72,
3353
3358
  challenge: 0.58,
3354
3359
  hindiVoice: DEFAULT_HINDI_VOICE,
3355
- marathiVoice: DEFAULT_MARATHI_VOICE
3360
+ marathiVoice: DEFAULT_MARATHI_VOICE,
3361
+ duck: true,
3362
+ duckPercent: 25
3356
3363
  };
3357
3364
  function sanitizeVoice(v) {
3358
3365
  const ok = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
3359
- if (ok(v.accept) && ok(v.challenge) && v.accept > v.challenge) return v;
3360
- return { ...v, accept: DEFAULT_VOICE.accept, challenge: DEFAULT_VOICE.challenge };
3366
+ const percent2 = typeof v.duckPercent === "number" && Number.isFinite(v.duckPercent) ? Math.max(0, Math.min(100, Math.round(v.duckPercent))) : DEFAULT_VOICE.duckPercent;
3367
+ const base = { ...v, duck: typeof v.duck === "boolean" ? v.duck : DEFAULT_VOICE.duck, duckPercent: percent2 };
3368
+ if (ok(v.accept) && ok(v.challenge) && v.accept > v.challenge) return base;
3369
+ return { ...base, accept: DEFAULT_VOICE.accept, challenge: DEFAULT_VOICE.challenge };
3361
3370
  }
3362
3371
  var DEFAULT_DESTRUCTIVE_PATTERNS = [
3363
3372
  String.raw`\brm\s+(-[a-z]*r|-[a-z]*f)`,
@@ -3404,6 +3413,7 @@ var defaults = (dataDir) => ({
3404
3413
  openai: { ...DEFAULT_OPENAI },
3405
3414
  claudeCode: { ...DEFAULT_CLAUDE_CODE },
3406
3415
  voice: { ...DEFAULT_VOICE },
3416
+ updates: sanitizeUpdates(void 0),
3407
3417
  view: "companion",
3408
3418
  look: { ...DEFAULT_LOOK },
3409
3419
  ownerName: "",
@@ -3450,6 +3460,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3450
3460
  openai: { ...DEFAULT_OPENAI, ...stored.openai },
3451
3461
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
3452
3462
  voice: sanitizeVoice({ ...DEFAULT_VOICE, ...stored.voice }),
3463
+ updates: sanitizeUpdates(stored.updates),
3453
3464
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
3454
3465
  look: sanitizeLook(stored.look),
3455
3466
  ownerName: typeof stored.ownerName === "string" ? stored.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "",
@@ -4876,9 +4887,9 @@ function chromeToken(home = homedir3()) {
4876
4887
  return null;
4877
4888
  }
4878
4889
  }
4879
- function sameKey(given, real2) {
4880
- if (!given || !real2) return false;
4881
- return timingSafeEqual(createHash3("sha256").update(given).digest(), createHash3("sha256").update(real2).digest());
4890
+ function sameKey(given, real3) {
4891
+ if (!given || !real3) return false;
4892
+ return timingSafeEqual(createHash3("sha256").update(given).digest(), createHash3("sha256").update(real3).digest());
4882
4893
  }
4883
4894
  function keyFromRequest(headers, search) {
4884
4895
  const bearer = /^Bearer (.+)$/i.exec(headers.authorization ?? "")?.[1];
@@ -5292,8 +5303,8 @@ var Mpv = class {
5292
5303
  seek(seconds) {
5293
5304
  return this.cmd(["seek", seconds, "absolute"]);
5294
5305
  }
5295
- volume(percent) {
5296
- return this.cmd(["set_property", "volume", percent]);
5306
+ volume(percent2) {
5307
+ return this.cmd(["set_property", "volume", percent2]);
5297
5308
  }
5298
5309
  mute(on) {
5299
5310
  return this.cmd(["set_property", "mute", on]);
@@ -5384,8 +5395,8 @@ var VideoHost = class {
5384
5395
  seek(seconds) {
5385
5396
  return this.mpv.seek(seconds);
5386
5397
  }
5387
- volume(percent) {
5388
- return this.mpv.volume(percent);
5398
+ volume(percent2) {
5399
+ return this.mpv.volume(percent2);
5389
5400
  }
5390
5401
  stop() {
5391
5402
  return this.mpv.stop();
@@ -5989,10 +6000,10 @@ var Provisioner = class {
5989
6000
  const list2 = await this.text(plan.sums.url);
5990
6001
  const want = checksumFor(list2, plan.sums.name);
5991
6002
  if (!want) return fail(`Friend could not check the ${name} download: no published checksum was found for it.`);
5992
- let got = await this.file(plan.url, part, (percent) => this.set(name, { phase: "downloading", percent }));
6003
+ let got = await this.file(plan.url, part, (percent2) => this.set(name, { phase: "downloading", percent: percent2 }));
5993
6004
  if (got.error === "failed") {
5994
6005
  rmSync4(part, { force: true });
5995
- got = await this.file(plan.url, part, (percent) => this.set(name, { phase: "downloading", percent }));
6006
+ got = await this.file(plan.url, part, (percent2) => this.set(name, { phase: "downloading", percent: percent2 }));
5996
6007
  }
5997
6008
  if (got.error) {
5998
6009
  this.d.onError?.(name, got.error === "big" ? "too big" : got.why ?? "download failed");
@@ -6083,10 +6094,10 @@ var Provisioner = class {
6083
6094
  }
6084
6095
  hash.update(value);
6085
6096
  if (!out.write(value)) await new Promise((r) => out.once("drain", () => r()));
6086
- const percent = total > 0 ? Math.min(99, Math.floor(received / total * 100)) : null;
6087
- if (percent !== lastPercent) {
6088
- lastPercent = percent ?? -1;
6089
- progress(percent);
6097
+ const percent2 = total > 0 ? Math.min(99, Math.floor(received / total * 100)) : null;
6098
+ if (percent2 !== lastPercent) {
6099
+ lastPercent = percent2 ?? -1;
6100
+ progress(percent2);
6090
6101
  }
6091
6102
  }
6092
6103
  await new Promise((resolve13, reject) => {
@@ -6346,6 +6357,10 @@ var VideoHub = class {
6346
6357
  tokens = /* @__PURE__ */ new Map();
6347
6358
  streams = [];
6348
6359
  cached = null;
6360
+ /** The volume the owner chose for the PC's player (mpv keeps it from one video to the next). */
6361
+ hostVolume = 100;
6362
+ /** How loud it is while someone talks: 1 is the owner's volume as it is. */
6363
+ duckLevel = 1;
6349
6364
  now = null;
6350
6365
  tickets = /* @__PURE__ */ new Map();
6351
6366
  clock() {
@@ -6490,6 +6505,8 @@ var VideoHub = class {
6490
6505
  const opened = await this.d.host.open(info.webpage || args.url || "", { start: start2, maxHeight: cfg.maxHeight });
6491
6506
  if (!opened.ok) return { ok: false, text: opened.error };
6492
6507
  this.now = { output, title: info.title, uploader: info.uploader, info, via: "mpv" };
6508
+ if (this.duckLevel < 1) await this.d.host.volume(this.hostLevel()).catch(() => {
6509
+ });
6493
6510
  return { ok: true, text: `Playing ${what} in a window on this PC.` };
6494
6511
  }
6495
6512
  if (browserWindow) {
@@ -6534,6 +6551,17 @@ var VideoHub = class {
6534
6551
  });
6535
6552
  this.now = null;
6536
6553
  }
6554
+ hostLevel() {
6555
+ return Math.round(this.hostVolume * this.duckLevel);
6556
+ }
6557
+ /** Turns the video in the PC's own player down (0.25 is a quarter) while someone talks, and back up (1) after. A video on the screen is turned down by the page. */
6558
+ async duck(level) {
6559
+ this.duckLevel = Math.max(0, Math.min(1, level));
6560
+ const now = this.now;
6561
+ if (now?.output !== "host" || now.via !== "mpv" || !this.d.host) return;
6562
+ await this.d.host.volume(this.hostLevel()).catch(() => {
6563
+ });
6564
+ }
6537
6565
  async control(args) {
6538
6566
  if (args.action === "stop") {
6539
6567
  const wasScreen = this.now?.output === "screen";
@@ -6549,7 +6577,10 @@ var VideoHub = class {
6549
6577
  if (args.action === "pause") await host.pause(true);
6550
6578
  else if (args.action === "resume") await host.pause(false);
6551
6579
  else if (args.action === "seek") await host.seek(Math.max(0, args.seconds ?? 0));
6552
- else await host.volume(Math.max(0, Math.min(100, args.level ?? 50)));
6580
+ else {
6581
+ this.hostVolume = Math.max(0, Math.min(100, args.level ?? 50));
6582
+ await host.volume(this.hostLevel());
6583
+ }
6553
6584
  } catch (e) {
6554
6585
  return { ok: false, text: e instanceof Error ? e.message : "The player did not answer." };
6555
6586
  }
@@ -7094,16 +7125,16 @@ function parseCsv(text3) {
7094
7125
  const rows3 = [];
7095
7126
  let row = [];
7096
7127
  let cell = "";
7097
- let quoted = false;
7128
+ let quoted2 = false;
7098
7129
  for (let i = 0; i < text3.length; i++) {
7099
7130
  const ch = text3[i];
7100
- if (quoted) {
7131
+ if (quoted2) {
7101
7132
  if (ch === '"' && text3[i + 1] === '"') {
7102
7133
  cell += '"';
7103
7134
  i++;
7104
- } else if (ch === '"') quoted = false;
7135
+ } else if (ch === '"') quoted2 = false;
7105
7136
  else cell += ch;
7106
- } else if (ch === '"') quoted = true;
7137
+ } else if (ch === '"') quoted2 = true;
7107
7138
  else if (ch === ",") {
7108
7139
  row.push(cell);
7109
7140
  cell = "";
@@ -7156,59 +7187,59 @@ var MediaStore = class {
7156
7187
  /** A file the assistant wants to send to the owner's chat: checked here (it must exist, be a real file, not be Friend's own data, not a program). */
7157
7188
  async sendable(rawPath) {
7158
7189
  if (!isAbsolute(rawPath)) return { ok: false, error: "Give the full path of the file, for example D:\\Pictures\\photo.png." };
7159
- let real2;
7190
+ let real3;
7160
7191
  try {
7161
- real2 = await fs.realpath(resolve4(rawPath));
7192
+ real3 = await fs.realpath(resolve4(rawPath));
7162
7193
  } catch {
7163
7194
  return { ok: false, error: "That file does not exist." };
7164
7195
  }
7165
- if (insideOf(await fs.realpath(this.dataDir).catch(() => this.dataDir), real2)) return { ok: false, error: "Friend's own data folder cannot be sent." };
7166
- const stat2 = await fs.stat(real2).catch(() => null);
7196
+ if (insideOf(await fs.realpath(this.dataDir).catch(() => this.dataDir), real3)) return { ok: false, error: "Friend's own data folder cannot be sent." };
7197
+ const stat2 = await fs.stat(real3).catch(() => null);
7167
7198
  if (!stat2 || !stat2.isFile()) return { ok: false, error: "That path is not a file." };
7168
- const name = basename(real2);
7169
- const ext = extname(real2).slice(1).toLowerCase();
7199
+ const name = basename(real3);
7200
+ const ext = extname(real3).slice(1).toLowerCase();
7170
7201
  if (NOT_SENT.has(ext)) return { ok: false, error: `Friend cannot send .${ext} files.` };
7171
7202
  if (stat2.size === 0) return { ok: false, error: "That file is empty." };
7172
7203
  if (stat2.size > MAX_SEND) return { ok: false, error: `That file is too big to send (${Math.round(stat2.size / 1048576)} MB).` };
7173
7204
  const mime = IMAGE[ext] ?? VIDEO[ext] ?? DOC_MIME[ext] ?? "application/octet-stream";
7174
7205
  const kind = mime.startsWith("image/") ? "image" : mime.startsWith("audio/") ? "audio" : mime.startsWith("video/") ? "video" : "document";
7175
- return { ok: true, path: real2, name, mime, size: stat2.size, kind };
7206
+ return { ok: true, path: real3, name, mime, size: stat2.size, kind };
7176
7207
  }
7177
7208
  async present(rawPath, title2) {
7178
7209
  if (!isAbsolute(rawPath)) return { ok: false, error: "Give the full path of the file, for example D:\\Pictures\\photo.png." };
7179
- let real2;
7210
+ let real3;
7180
7211
  try {
7181
- real2 = await fs.realpath(resolve4(rawPath));
7212
+ real3 = await fs.realpath(resolve4(rawPath));
7182
7213
  } catch {
7183
7214
  return { ok: false, error: "That file does not exist." };
7184
7215
  }
7185
- if (insideOf(await fs.realpath(this.dataDir).catch(() => this.dataDir), real2)) return { ok: false, error: "Friend's own data folder cannot be shown." };
7186
- const stat2 = await fs.stat(real2).catch(() => null);
7216
+ if (insideOf(await fs.realpath(this.dataDir).catch(() => this.dataDir), real3)) return { ok: false, error: "Friend's own data folder cannot be shown." };
7217
+ const stat2 = await fs.stat(real3).catch(() => null);
7187
7218
  if (!stat2 || !stat2.isFile()) return { ok: false, error: "That path is not a file." };
7188
- const name = basename(real2);
7189
- const ext = extname(real2).slice(1).toLowerCase();
7219
+ const name = basename(real3);
7220
+ const ext = extname(real3).slice(1).toLowerCase();
7190
7221
  const label3 = title2?.trim() || name;
7191
7222
  const tooBig = (limit, what) => stat2.size > limit ? { ok: false, error: `That ${what} is too large to show (${Math.round(stat2.size / 1048576)} MB).` } : null;
7192
7223
  if (IMAGE[ext]) {
7193
7224
  const big = tooBig(MAX_IMAGE, "image");
7194
7225
  if (big) return big;
7195
- const id2 = this.grant(real2, IMAGE[ext], stat2.size);
7226
+ const id2 = this.grant(real3, IMAGE[ext], stat2.size);
7196
7227
  return { ok: true, type: "image", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing the image ${name}.` };
7197
7228
  }
7198
7229
  if (VIDEO[ext]) {
7199
7230
  const big = tooBig(MAX_VIDEO, "file");
7200
7231
  if (big) return big;
7201
- const id2 = this.grant(real2, VIDEO[ext], stat2.size);
7232
+ const id2 = this.grant(real3, VIDEO[ext], stat2.size);
7202
7233
  return { ok: true, type: "video", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing ${name}.` };
7203
7234
  }
7204
7235
  if (ext === "pdf") {
7205
7236
  const big = tooBig(MAX_PDF, "PDF");
7206
7237
  if (big) return big;
7207
- const id2 = this.grant(real2, "application/pdf", stat2.size);
7238
+ const id2 = this.grant(real3, "application/pdf", stat2.size);
7208
7239
  return { ok: true, type: "pdf", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing the PDF ${name}.` };
7209
7240
  }
7210
7241
  if (MARKDOWN.has(ext) || ext === "csv" || CODE_LANG[ext]) {
7211
- const handle = await fs.open(real2, "r");
7242
+ const handle = await fs.open(real3, "r");
7212
7243
  let text3;
7213
7244
  try {
7214
7245
  const buf = Buffer.alloc(Math.min(stat2.size, MAX_TEXT));
@@ -8907,6 +8938,8 @@ var HostPlayer = class {
8907
8938
  chain = Promise.resolve();
8908
8939
  off;
8909
8940
  disposed = false;
8941
+ /** How loud it is while someone talks: 1 is the owner's volume as it is. */
8942
+ duckLevel = 1;
8910
8943
  now = () => (this.d.clock ?? Date.now)();
8911
8944
  available() {
8912
8945
  const problem = programProblem(this.d.settings().hostPlayer.trim());
@@ -9090,14 +9123,24 @@ var HostPlayer = class {
9090
9123
  if (this.engine === "folder") await this.d.mpv.seek(seconds);
9091
9124
  else if (this.engine === "spotify" && this.deviceId) await this.d.spotify.control(this.deviceId, { action: "seek", ms: Math.round(seconds * 1e3) });
9092
9125
  }
9126
+ /** The owner's volume, turned down while someone is talking. */
9127
+ level() {
9128
+ return Math.round(this.s.volume * this.duckLevel);
9129
+ }
9093
9130
  async applyVolume() {
9094
9131
  if (this.engine === "folder") {
9095
- await this.d.mpv.volume(this.s.volume);
9132
+ await this.d.mpv.volume(this.level());
9096
9133
  await this.d.mpv.mute(this.s.muted);
9097
9134
  } else if (this.engine === "spotify" && this.deviceId) {
9098
- await this.d.spotify.control(this.deviceId, { action: "volume", percent: this.s.muted ? 0 : this.s.volume });
9135
+ await this.d.spotify.control(this.deviceId, { action: "volume", percent: this.s.muted ? 0 : this.level() });
9099
9136
  }
9100
9137
  }
9138
+ /** Turns the sound down (0.25 is a quarter) while the owner talks to Friend or Friend speaks, and back up (1) after. The owner's own volume is not changed. */
9139
+ async duck(level) {
9140
+ this.duckLevel = Math.max(0, Math.min(1, level));
9141
+ if (this.disposed || !this.engine) return;
9142
+ await this.run(() => this.applyVolume());
9143
+ }
9101
9144
  // ---- what the engines tell us ----
9102
9145
  onMpv(e) {
9103
9146
  if (this.disposed) return;
@@ -9360,8 +9403,8 @@ var MusicLibrary = class {
9360
9403
  try {
9361
9404
  const info = await lstat(path3);
9362
9405
  if (!info.isFile() || info.isSymbolicLink()) return null;
9363
- const real2 = await realpath(path3);
9364
- return real2.startsWith(this.root + sep2) ? path3 : null;
9406
+ const real3 = await realpath(path3);
9407
+ return real3.startsWith(this.root + sep2) ? path3 : null;
9365
9408
  } catch {
9366
9409
  return null;
9367
9410
  }
@@ -10568,7 +10611,7 @@ var Memory = class {
10568
10611
  };
10569
10612
 
10570
10613
  // ../server/src/app.ts
10571
- import { dirname as dirname5, extname as extname3, resolve as resolve5 } from "node:path";
10614
+ import { dirname as dirname6, extname as extname3, resolve as resolve5 } from "node:path";
10572
10615
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10573
10616
  import { join as join18 } from "node:path";
10574
10617
 
@@ -11542,6 +11585,45 @@ function createGateway(deps) {
11542
11585
  if (!deps.video) return reply.code(404).send({ error: "Not found" });
11543
11586
  return deps.video.prepare();
11544
11587
  });
11588
+ const systemWindow = new SlidingWindow(30, 6e4);
11589
+ const systemGuard = async (req, reply) => {
11590
+ const token = req.cookies[COOKIE_NAME];
11591
+ const session = token ? auth.resolveSession(token) : null;
11592
+ if (!session) return reply.code(401).send({ error: "Unauthorized" });
11593
+ if (!systemWindow.allow(String(session.id))) return reply.code(429).header("retry-after", 10).send({ error: "Too many requests" });
11594
+ if (!deps.system) return reply.code(503).send({ error: "Not available" });
11595
+ };
11596
+ const systemError = (reply, e, what) => {
11597
+ if (e instanceof HttpError) return reply.code(e.status).send({ error: e.message });
11598
+ audit.log("owner", "system.failed", { what, message: e.message });
11599
+ return reply.code(500).send({ error: "That did not work. Try again." });
11600
+ };
11601
+ app.get("/api/system", { onRequest: systemGuard }, async () => deps.system.info());
11602
+ app.post("/api/system/update/check", { onRequest: systemGuard }, async (_req, reply) => {
11603
+ try {
11604
+ return await deps.system.check();
11605
+ } catch (e) {
11606
+ return systemError(reply, e, "update.check");
11607
+ }
11608
+ });
11609
+ app.post("/api/system/update/apply", { onRequest: systemGuard }, async (req, reply) => {
11610
+ const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
11611
+ if (!body.success) return reply.code(400).send({ error: "Enter your PIN" });
11612
+ try {
11613
+ return await deps.system.update(body.data.pin, req.ip);
11614
+ } catch (e) {
11615
+ return systemError(reply, e, "update.apply");
11616
+ }
11617
+ });
11618
+ app.post("/api/system/autostart", { onRequest: systemGuard }, async (req, reply) => {
11619
+ const body = z10.object({ enabled: z10.boolean(), pin: z10.string().min(1).max(32) }).safeParse(req.body);
11620
+ if (!body.success) return reply.code(400).send({ error: "Enter your PIN" });
11621
+ try {
11622
+ return await deps.system.setAutostart(body.data.enabled, body.data.pin, req.ip);
11623
+ } catch (e) {
11624
+ return systemError(reply, e, "autostart");
11625
+ }
11626
+ });
11545
11627
  const chromeFailures = new SlidingWindow(20, 6e4);
11546
11628
  const chromeWss = new WebSocketServer({ noServer: true, maxPayload: MAX_DEVICE_FRAME_BYTES });
11547
11629
  if (deps.chrome) {
@@ -12956,8 +13038,11 @@ var SettingsPatch = z11.object({
12956
13038
  /** A Piper voice name; empty makes the main voice read Hindi too. */
12957
13039
  hindiVoice: z11.string().trim().max(80).regex(/^[A-Za-z0-9_-]*$/, "A Piper voice name such as hi_IN-priyamvada-medium, or empty"),
12958
13040
  marathiVoice: z11.string().trim().max(80).regex(/^[A-Za-z0-9_-]*$/, "A Piper voice name such as mr_IN-google-medium, or empty"),
12959
- sttModel: z11.string().trim().min(1).max(80).regex(/^[\w./-]+$/)
12960
- }).partial().strict()
13041
+ sttModel: z11.string().trim().min(1).max(80).regex(/^[\w./-]+$/),
13042
+ duck: z11.boolean(),
13043
+ duckPercent: z11.number().int().min(0).max(100)
13044
+ }).partial().strict(),
13045
+ updates: z11.object({ auto: z11.boolean() }).partial().strict()
12961
13046
  }).partial().strict();
12962
13047
  function settingsView(c) {
12963
13048
  return {
@@ -13017,8 +13102,11 @@ function settingsView(c) {
13017
13102
  ttsVoice: c.voice.ttsVoice,
13018
13103
  sttModel: c.voice.sttModel,
13019
13104
  hindiVoice: c.voice.hindiVoice,
13020
- marathiVoice: c.voice.marathiVoice
13021
- }
13105
+ marathiVoice: c.voice.marathiVoice,
13106
+ duck: c.voice.duck,
13107
+ duckPercent: c.voice.duckPercent
13108
+ },
13109
+ updates: { auto: c.updates.auto }
13022
13110
  };
13023
13111
  }
13024
13112
  var SettingsError = class extends Error {
@@ -13030,6 +13118,7 @@ function refuseForAgent(raw) {
13030
13118
  if ("brokers" in r || "trading" in r) throw new SettingsError("Broker accounts and the trading level can only be changed by the owner on the settings screen.");
13031
13119
  if ("music" in r || "hud" in r) throw new SettingsError("The music folder and the screen widgets can only be changed by the owner on the settings screen.");
13032
13120
  if ("video" in r) throw new SettingsError("Video settings (which programs run, where videos play, which sites) can only be changed by the owner on the settings screen.");
13121
+ if ("updates" in r) throw new SettingsError("Whether Friend updates itself can only be changed by the owner on the settings screen.");
13033
13122
  if ("alexa" in r) throw new SettingsError("Alexa settings can only be changed by the owner on the settings screen.");
13034
13123
  if ("channels" in r) throw new SettingsError("Messaging channels can only be set up by the owner on the settings screen.");
13035
13124
  if ("approvals" in r) throw new SettingsError("How much Friend asks before acting can only be changed by the owner on the settings screen.");
@@ -13174,6 +13263,7 @@ function applySettings(config, raw) {
13174
13263
  if (apiKey) config.openai.apiKey = apiKey;
13175
13264
  }
13176
13265
  if (p.claudeCode) Object.assign(config.claudeCode, p.claudeCode);
13266
+ if (p.updates) Object.assign(config.updates, p.updates);
13177
13267
  if (p.voice) {
13178
13268
  const safe = next.voice;
13179
13269
  for (const k of Object.keys(p.voice)) config.voice[k] = safe[k];
@@ -13609,6 +13699,404 @@ async function ensureTlsCert(dataDir, extraHosts = [], ifaces, now = Date.now) {
13609
13699
  return { key: pems.private, cert: pems.cert };
13610
13700
  }
13611
13701
 
13702
+ // ../server/src/audio/duck.ts
13703
+ var FALLBACK_PERCENT = 25;
13704
+ var DuckController = class {
13705
+ constructor(d) {
13706
+ this.d = d;
13707
+ }
13708
+ d;
13709
+ active = /* @__PURE__ */ new Set();
13710
+ current = 1;
13711
+ /** How loud things should be now: 1 as normal. */
13712
+ get level() {
13713
+ return this.current;
13714
+ }
13715
+ set(source, active) {
13716
+ if (active) this.active.add(source);
13717
+ else this.active.delete(source);
13718
+ this.update();
13719
+ }
13720
+ /** A screen went away. */
13721
+ drop(source) {
13722
+ if (this.active.delete(source)) this.update();
13723
+ }
13724
+ /** The owner changed the settings. */
13725
+ refresh() {
13726
+ this.update();
13727
+ }
13728
+ update() {
13729
+ const raw = this.d.percent();
13730
+ const percent2 = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : FALLBACK_PERCENT;
13731
+ const next = this.d.enabled() && this.active.size > 0 ? percent2 / 100 : 1;
13732
+ if (next === this.current) return;
13733
+ this.current = next;
13734
+ this.d.apply(next);
13735
+ }
13736
+ };
13737
+
13738
+ // ../server/src/system/autostart.ts
13739
+ import { spawn as spawn6 } from "node:child_process";
13740
+ import { existsSync as existsSync16, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync9, writeFileSync as writeFileSync10 } from "node:fs";
13741
+ import { homedir as homedir4 } from "node:os";
13742
+ import { dirname as dirname4, posix as posix2, win32 as win322 } from "node:path";
13743
+ var RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
13744
+ var NAME2 = "Friend";
13745
+ var LABEL = "com.friend.assistant";
13746
+ var UNIT = "friend.service";
13747
+ var quoted = (s) => `"${s}"`;
13748
+ function vbsLauncher(self) {
13749
+ const cmd = `${quoted(self.node)} ${quoted(self.script)} daemon start`;
13750
+ const inVbs = cmd.replace(/"/g, '""');
13751
+ return [`' Starts Friend in the background when you sign in. Made by: friend autostart on`, `CreateObject("WScript.Shell").Run "${inVbs}", 0, False`, ""].join("\r\n");
13752
+ }
13753
+ var xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13754
+ function plistFor(self, o) {
13755
+ const log = `${o.dataDir}/friend-autostart.log`;
13756
+ return [
13757
+ `<?xml version="1.0" encoding="UTF-8"?>`,
13758
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
13759
+ `<plist version="1.0">`,
13760
+ `<dict>`,
13761
+ ` <key>Label</key><string>${LABEL}</string>`,
13762
+ ` <key>ProgramArguments</key>`,
13763
+ ` <array>`,
13764
+ ` <string>${xml(self.node)}</string>`,
13765
+ ` <string>${xml(self.script)}</string>`,
13766
+ ` <string>daemon</string>`,
13767
+ ` <string>start</string>`,
13768
+ ` </array>`,
13769
+ ` <key>RunAtLoad</key>`,
13770
+ ` <true/>`,
13771
+ ` <key>EnvironmentVariables</key>`,
13772
+ ` <dict><key>PATH</key><string>${xml(o.path)}</string></dict>`,
13773
+ ` <key>StandardOutPath</key><string>${xml(log)}</string>`,
13774
+ ` <key>StandardErrorPath</key><string>${xml(log)}</string>`,
13775
+ `</dict>`,
13776
+ `</plist>`,
13777
+ ""
13778
+ ].join("\n");
13779
+ }
13780
+ var unitWord = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
13781
+ function systemdUnit(self, o) {
13782
+ return [
13783
+ "[Unit]",
13784
+ "Description=Friend assistant",
13785
+ "After=network-online.target",
13786
+ "",
13787
+ "[Service]",
13788
+ "Type=oneshot",
13789
+ "RemainAfterExit=yes",
13790
+ `Environment=${unitWord(`PATH=${o.path}`)}`,
13791
+ `ExecStart=${unitWord(self.node)} ${unitWord(self.script)} daemon start`,
13792
+ `ExecStop=${unitWord(self.node)} ${unitWord(self.script)} daemon stop`,
13793
+ "",
13794
+ "[Install]",
13795
+ "WantedBy=default.target",
13796
+ ""
13797
+ ].join("\n");
13798
+ }
13799
+ var desktopWord = (s) => `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
13800
+ function desktopEntry(self) {
13801
+ return ["[Desktop Entry]", "Type=Application", "Name=Friend", "Comment=Starts Friend in the background", `Exec=${desktopWord(self.node)} ${desktopWord(self.script)} daemon start`, "Terminal=false", "X-GNOME-Autostart-enabled=true", ""].join("\n");
13802
+ }
13803
+ var Autostart = class {
13804
+ constructor(d, self) {
13805
+ this.d = d;
13806
+ this.self = self;
13807
+ }
13808
+ d;
13809
+ self;
13810
+ get win() {
13811
+ return this.d.platform === "win32";
13812
+ }
13813
+ get mac() {
13814
+ return this.d.platform === "darwin";
13815
+ }
13816
+ get linux() {
13817
+ return this.d.platform === "linux";
13818
+ }
13819
+ p() {
13820
+ return this.win ? win322 : posix2;
13821
+ }
13822
+ vbs() {
13823
+ return win322.join(this.d.dataDir, "autostart.vbs");
13824
+ }
13825
+ plist() {
13826
+ return posix2.join(this.d.home, "Library", "LaunchAgents", `${LABEL}.plist`);
13827
+ }
13828
+ configDir() {
13829
+ return this.d.configHome || posix2.join(this.d.home, ".config");
13830
+ }
13831
+ unitFile() {
13832
+ return posix2.join(this.configDir(), "systemd", "user", UNIT);
13833
+ }
13834
+ desktopFile() {
13835
+ return posix2.join(this.configDir(), "autostart", "friend.desktop");
13836
+ }
13837
+ async hasSystemd() {
13838
+ return (await this.d.run("systemctl", ["--user", "--version"])).code === 0;
13839
+ }
13840
+ async status() {
13841
+ if (this.win) return { supported: true, enabled: (await this.d.run("reg", ["query", RUN_KEY, "/v", NAME2])).code === 0, kind: "registry" };
13842
+ if (this.mac) return { supported: true, enabled: this.d.exists(this.plist()), kind: "launchd" };
13843
+ if (this.linux) {
13844
+ if (await this.hasSystemd()) {
13845
+ const r = await this.d.run("systemctl", ["--user", "is-enabled", UNIT]);
13846
+ if (r.code === 0 && /enabled/.test(r.stdout)) return { supported: true, enabled: true, kind: "systemd" };
13847
+ }
13848
+ return { supported: true, enabled: this.d.exists(this.desktopFile()), kind: this.d.exists(this.desktopFile()) ? "xdg" : "none" };
13849
+ }
13850
+ return { supported: false, enabled: false, kind: "none" };
13851
+ }
13852
+ async enable() {
13853
+ const self = this.self();
13854
+ if (!self.node || !self.script) return { ok: false, message: "Friend does not know which program to start. Run it with the friend command and try again." };
13855
+ if (this.win) return this.enableWindows(self);
13856
+ if (this.mac) return this.enableMac(self);
13857
+ if (this.linux) return this.enableLinux(self);
13858
+ return { ok: false, message: "Starting Friend by itself is not supported on this kind of computer." };
13859
+ }
13860
+ async disable() {
13861
+ if (this.win) {
13862
+ await this.d.run("reg", ["delete", RUN_KEY, "/v", NAME2, "/f"]);
13863
+ this.d.remove(this.vbs());
13864
+ } else if (this.mac) {
13865
+ await this.d.run("launchctl", ["bootout", `gui/${this.d.uid}`, this.plist()]);
13866
+ this.d.remove(this.plist());
13867
+ } else if (this.linux) {
13868
+ if (await this.hasSystemd()) {
13869
+ await this.d.run("systemctl", ["--user", "disable", UNIT]);
13870
+ this.d.remove(this.unitFile());
13871
+ await this.d.run("systemctl", ["--user", "daemon-reload"]);
13872
+ }
13873
+ this.d.remove(this.desktopFile());
13874
+ } else return { ok: false, message: "Starting Friend by itself is not supported on this kind of computer." };
13875
+ return { ok: true, message: "Friend will no longer start by itself when you sign in." };
13876
+ }
13877
+ /** Writes the setup again with the program's current place, when it is on. Used after an upgrade. */
13878
+ async refresh() {
13879
+ if ((await this.status()).enabled) await this.enable();
13880
+ }
13881
+ async enableWindows(self) {
13882
+ this.d.write(this.vbs(), vbsLauncher(self));
13883
+ const r = await this.d.run("reg", ["add", RUN_KEY, "/v", NAME2, "/t", "REG_SZ", "/d", `wscript.exe ${quoted(this.vbs())}`, "/f"]);
13884
+ if (r.code !== 0) return { ok: false, message: "Could not add Friend to the programs that start when you sign in." };
13885
+ return { ok: true, message: "Friend will start by itself when you sign in to this computer." };
13886
+ }
13887
+ async enableMac(self) {
13888
+ this.d.write(this.plist(), plistFor(self, { dataDir: this.d.dataDir, path: this.d.path }));
13889
+ await this.d.run("launchctl", ["bootout", `gui/${this.d.uid}`, this.plist()]);
13890
+ let r = await this.d.run("launchctl", ["bootstrap", `gui/${this.d.uid}`, this.plist()]);
13891
+ if (r.code !== 0) r = await this.d.run("launchctl", ["load", "-w", this.plist()]);
13892
+ if (r.code !== 0) return { ok: false, message: "Could not switch the launch agent on. It will still be used the next time you sign in." };
13893
+ return { ok: true, message: "Friend will start by itself when you sign in to this computer." };
13894
+ }
13895
+ async enableLinux(self) {
13896
+ if (await this.hasSystemd()) {
13897
+ this.d.write(this.unitFile(), systemdUnit(self, { path: this.d.path }));
13898
+ await this.d.run("systemctl", ["--user", "daemon-reload"]);
13899
+ const r = await this.d.run("systemctl", ["--user", "enable", UNIT]);
13900
+ if (r.code === 0) {
13901
+ this.d.remove(this.desktopFile());
13902
+ return { ok: true, message: "Friend will start by itself when you sign in to this computer." };
13903
+ }
13904
+ }
13905
+ this.d.write(this.desktopFile(), desktopEntry(self));
13906
+ return { ok: true, message: "Friend will start by itself when you sign in to this computer." };
13907
+ }
13908
+ };
13909
+ function realAutostartDeps(dataDir) {
13910
+ return {
13911
+ platform: process.platform,
13912
+ home: homedir4(),
13913
+ dataDir,
13914
+ uid: typeof process.getuid === "function" ? process.getuid() : 0,
13915
+ path: process.env.PATH ?? "",
13916
+ ...process.env.XDG_CONFIG_HOME ? { configHome: process.env.XDG_CONFIG_HOME } : {},
13917
+ exists: (p) => existsSync16(p),
13918
+ read: (p) => {
13919
+ try {
13920
+ return readFileSync10(p, "utf8");
13921
+ } catch {
13922
+ return null;
13923
+ }
13924
+ },
13925
+ write: (p, text3) => {
13926
+ mkdirSync11(dirname4(p), { recursive: true });
13927
+ writeFileSync10(p, text3);
13928
+ },
13929
+ remove: (p) => rmSync9(p, { force: true }),
13930
+ run: (cmd, args) => new Promise((resolve13) => {
13931
+ let out = "";
13932
+ const child = spawn6(cmd, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
13933
+ child.stdout?.on("data", (d) => out += d.toString());
13934
+ child.stderr?.on("data", (d) => out += d.toString());
13935
+ child.on("error", () => resolve13({ code: 127, stdout: out }));
13936
+ child.on("close", (code) => resolve13({ code: code ?? 1, stdout: out }));
13937
+ })
13938
+ };
13939
+ }
13940
+ function currentSelf() {
13941
+ const script = process.argv[1] ?? "";
13942
+ return { node: process.execPath, script: /\.(mjs|js|cjs)$/i.test(script) ? script : "" };
13943
+ }
13944
+
13945
+ // ../server/src/system/npm.ts
13946
+ import { spawn as spawn7 } from "node:child_process";
13947
+ var PACKAGE = "friend-assistant";
13948
+ var runningVersion = () => true ? "0.1.45" : "development";
13949
+ async function npmLatest(fetchFn = fetch, timeoutMs = 1e4) {
13950
+ try {
13951
+ const res = await fetchFn(`https://registry.npmjs.org/${PACKAGE}/latest`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
13952
+ if (!res.ok) return null;
13953
+ const v = (await res.json()).version;
13954
+ return typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v) ? v : null;
13955
+ } catch {
13956
+ return null;
13957
+ }
13958
+ }
13959
+ var ps = (s) => `'${s.replace(/'/g, "''")}'`;
13960
+ function spawnUpgrade(self, platform = process.platform) {
13961
+ if (!self.node || !self.script) return false;
13962
+ try {
13963
+ const child = platform === "win32" ? spawn7("powershell.exe", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", `Start-Process -WindowStyle Hidden -FilePath ${ps(self.node)} -ArgumentList ${ps(`"${self.script}"`)},'upgrade','--auto'`], { detached: true, stdio: "ignore", windowsHide: true }) : spawn7(self.node, [self.script, "upgrade", "--auto"], { detached: true, stdio: "ignore" });
13964
+ child.on("error", () => {
13965
+ });
13966
+ child.unref();
13967
+ return child.pid !== void 0;
13968
+ } catch {
13969
+ return false;
13970
+ }
13971
+ }
13972
+
13973
+ // ../server/src/system/updater.ts
13974
+ function isNewerVersion(a, b) {
13975
+ if (!/^\d/.test(a) || !/^\d/.test(b)) return false;
13976
+ const n = (v) => v.split(/[.-]/).slice(0, 3).map((x2) => Number(x2) || 0);
13977
+ const [x, y] = [n(a), n(b)];
13978
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
13979
+ return false;
13980
+ }
13981
+ var Updater = class {
13982
+ constructor(d, t = {}) {
13983
+ this.d = d;
13984
+ this.firstMs = t.firstMs ?? 2e4;
13985
+ this.everyMs = t.everyMs ?? 6 * 36e5;
13986
+ this.retryMs = t.retryMs ?? 5 * 6e4;
13987
+ }
13988
+ d;
13989
+ latest = null;
13990
+ checkedAt = null;
13991
+ applying = false;
13992
+ error = null;
13993
+ notified = null;
13994
+ inflight = null;
13995
+ first = null;
13996
+ every = null;
13997
+ retry = null;
13998
+ firstMs;
13999
+ everyMs;
14000
+ retryMs;
14001
+ state() {
14002
+ const current = this.d.current();
14003
+ return { current, latest: this.latest, checkedAt: this.checkedAt, available: this.latest !== null && isNewerVersion(this.latest, current), applying: this.applying, canApply: this.d.canApply(), error: this.error };
14004
+ }
14005
+ start() {
14006
+ this.stop();
14007
+ this.first = setTimeout(() => {
14008
+ this.first = null;
14009
+ void this.check();
14010
+ this.every = setInterval(() => void this.check(), this.everyMs);
14011
+ this.every.unref?.();
14012
+ }, this.firstMs);
14013
+ this.first.unref?.();
14014
+ }
14015
+ stop() {
14016
+ if (this.first) clearTimeout(this.first);
14017
+ if (this.every) clearInterval(this.every);
14018
+ if (this.retry) clearTimeout(this.retry);
14019
+ this.first = this.every = this.retry = null;
14020
+ }
14021
+ /** Asks npm for the newest version (one question at a time), tells the owner, and updates when that is switched on. */
14022
+ check() {
14023
+ this.inflight ??= (async () => {
14024
+ try {
14025
+ const latest = await this.d.latest();
14026
+ this.checkedAt = Date.now();
14027
+ if (latest) {
14028
+ this.latest = latest;
14029
+ this.error = null;
14030
+ await this.consider();
14031
+ } else {
14032
+ this.error = "Could not ask npm for the newest version. Check the internet connection.";
14033
+ }
14034
+ } catch {
14035
+ this.error = "Could not ask npm for the newest version. Check the internet connection.";
14036
+ } finally {
14037
+ this.inflight = null;
14038
+ }
14039
+ return this.state();
14040
+ })();
14041
+ return this.inflight;
14042
+ }
14043
+ /** The owner pressed Update now: installs even when automatic updates are off, but never in the middle of an answer. */
14044
+ async applyNow() {
14045
+ if (this.applying) return { ok: true, message: "The update is already running. Reload this page in a minute." };
14046
+ if (!this.latest) await this.check();
14047
+ const s = this.state();
14048
+ if (!s.latest) return { ok: false, message: s.error ?? "Could not ask npm for the newest version." };
14049
+ if (this.applying) return { ok: true, message: "The update is already running. Reload this page in a minute." };
14050
+ if (!s.available) return { ok: false, message: `Friend is up to date (${s.current}).` };
14051
+ if (!s.canApply) return { ok: false, message: "This Friend is running in a terminal and cannot restart itself. Close it and run: friend upgrade" };
14052
+ if (this.d.busy()) return { ok: false, message: "Friend is busy with a request. Try again in a moment." };
14053
+ return await this.begin(s.latest) ? { ok: true, message: `Updating to ${s.latest}. Friend restarts in a moment: reload this page afterwards.` } : { ok: false, message: this.error ?? "Could not start the update." };
14054
+ }
14055
+ async consider() {
14056
+ const s = this.state();
14057
+ if (!s.available || !s.latest) return;
14058
+ const canApply = this.d.canApply();
14059
+ if (this.notified !== s.latest) {
14060
+ this.notified = s.latest;
14061
+ this.d.notify(
14062
+ canApply ? this.d.auto() ? `Friend ${s.latest} is available. It will update by itself when Friend is not busy.` : `Friend ${s.latest} is available. Turn on automatic updates in Settings, or press Update now there.` : `Friend ${s.latest} is available. Close this Friend and run: friend upgrade`
14063
+ );
14064
+ }
14065
+ if (this.d.auto() && canApply) await this.maybeApply();
14066
+ }
14067
+ async maybeApply() {
14068
+ const s = this.state();
14069
+ if (this.applying || !s.available || !s.latest) return;
14070
+ if (this.d.busy()) {
14071
+ if (!this.retry) {
14072
+ this.retry = setTimeout(() => {
14073
+ this.retry = null;
14074
+ void this.maybeApply();
14075
+ }, this.retryMs);
14076
+ this.retry.unref?.();
14077
+ }
14078
+ return;
14079
+ }
14080
+ await this.begin(s.latest);
14081
+ }
14082
+ async begin(version) {
14083
+ this.applying = true;
14084
+ this.error = null;
14085
+ this.d.notify(`Updating Friend to ${version}. It restarts in a moment: reload this page afterwards.`);
14086
+ let ok = false;
14087
+ try {
14088
+ ok = await this.d.apply(version);
14089
+ } catch {
14090
+ ok = false;
14091
+ }
14092
+ if (!ok) {
14093
+ this.applying = false;
14094
+ this.error = "Could not start the update.";
14095
+ }
14096
+ return ok;
14097
+ }
14098
+ };
14099
+
13612
14100
  // ../server/src/voice/challenge.ts
13613
14101
  import { randomUUID as randomUUID6 } from "node:crypto";
13614
14102
  var Challenges = class {
@@ -13670,20 +14158,20 @@ var Challenges = class {
13670
14158
 
13671
14159
  // ../server/src/voice/crypto.ts
13672
14160
  import { createCipheriv, createDecipheriv, randomBytes as randomBytes9 } from "node:crypto";
13673
- import { chmodSync as chmodSync9, existsSync as existsSync16, mkdirSync as mkdirSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync10 } from "node:fs";
13674
- import { dirname as dirname4 } from "node:path";
14161
+ import { chmodSync as chmodSync9, existsSync as existsSync17, mkdirSync as mkdirSync12, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "node:fs";
14162
+ import { dirname as dirname5 } from "node:path";
13675
14163
  var KEY_BYTES = 32;
13676
14164
  var IV_BYTES = 12;
13677
14165
  var TAG_BYTES = 16;
13678
14166
  function loadOrCreateKey(path3) {
13679
- if (existsSync16(path3)) {
13680
- const key2 = readFileSync10(path3);
14167
+ if (existsSync17(path3)) {
14168
+ const key2 = readFileSync11(path3);
13681
14169
  if (key2.length !== KEY_BYTES) throw new Error(`invalid voice key file (expected ${KEY_BYTES} bytes): ${path3}`);
13682
14170
  return key2;
13683
14171
  }
13684
- mkdirSync11(dirname4(path3), { recursive: true });
14172
+ mkdirSync12(dirname5(path3), { recursive: true });
13685
14173
  const key = randomBytes9(KEY_BYTES);
13686
- writeFileSync10(path3, key, { mode: 384 });
14174
+ writeFileSync11(path3, key, { mode: 384 });
13687
14175
  try {
13688
14176
  chmodSync9(path3, 384);
13689
14177
  } catch {
@@ -14484,6 +14972,7 @@ async function createApp(opts) {
14484
14972
  else swap();
14485
14973
  }
14486
14974
  if (applied.changed.includes("voice.ttsVoice")) void warmVoice(config.voice.ttsVoice, voiceBefore);
14975
+ if (applied.changed.some((k) => k === "voice.duck" || k === "voice.duckPercent")) ducker.refresh();
14487
14976
  if (applied.changed.includes("voice.sttModel")) void warmListening(config.voice.sttModel, sttBefore);
14488
14977
  if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
14489
14978
  if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
@@ -14767,7 +15256,7 @@ async function createApp(opts) {
14767
15256
  broadcast({ type: "voice.heard", text: held.text, trust: "owner" });
14768
15257
  await runVoiceTurn(held.text, conn.ip, epoch);
14769
15258
  }
14770
- const here2 = dirname5(fileURLToPath2(import.meta.url));
15259
+ const here2 = dirname6(fileURLToPath2(import.meta.url));
14771
15260
  const catalogDir = opts.pluginCatalogDir ?? firstExistingDir([process.env.FRIEND_PLUGIN_CATALOG, resolve5(here2, "plugins"), resolve5(here2, "../../..", "plugins")]) ?? resolve5(here2, "plugins");
14772
15261
  const pluginsApi = {
14773
15262
  list: () => {
@@ -14925,11 +15414,56 @@ async function createApp(opts) {
14925
15414
  return key;
14926
15415
  }
14927
15416
  };
15417
+ const duckIds = /* @__PURE__ */ new WeakMap();
15418
+ let duckSeq = 0;
15419
+ const duckId = (conn) => duckIds.get(conn) ?? (duckIds.set(conn, `screen-${++duckSeq}`), duckIds.get(conn));
15420
+ const ducker = new DuckController({
15421
+ enabled: () => config.voice.duck,
15422
+ percent: () => config.voice.duckPercent,
15423
+ apply: (level) => {
15424
+ broadcast({ type: "audio.duck", level });
15425
+ void hostPlayer.duck(level).catch(() => {
15426
+ });
15427
+ void videoHub.duck(level).catch(() => {
15428
+ });
15429
+ }
15430
+ });
15431
+ const self = opts.system?.self ?? currentSelf();
15432
+ const autostart = new Autostart(opts.system?.autostartDeps ?? realAutostartDeps(opts.dataDir), () => self);
15433
+ const updater = new Updater(
15434
+ {
15435
+ current: opts.system?.current ?? runningVersion,
15436
+ latest: opts.system?.latest ?? (() => npmLatest()),
15437
+ auto: () => config.updates.auto,
15438
+ busy: () => agent.isBusy(),
15439
+ canApply: opts.system?.canApply ?? (() => process.env.FRIEND_DAEMON === "1"),
15440
+ // only Friend in the background can replace itself
15441
+ apply: opts.system?.apply ?? (() => spawnUpgrade(self)),
15442
+ notify: (text3) => broadcast({ type: "alert", level: "info", text: text3 })
15443
+ },
15444
+ opts.system?.times
15445
+ );
15446
+ const systemApi = {
15447
+ info: async () => ({ version: updater.state().current, update: updater.state(), autostart: await autostart.status(), auto: config.updates.auto }),
15448
+ check: () => updater.check(),
15449
+ update: async (pin, ip) => {
15450
+ await requirePin(pin, ip);
15451
+ audit.log("owner", "system.update_now", { ip });
15452
+ return updater.applyNow();
15453
+ },
15454
+ setAutostart: async (enabled, pin, ip) => {
15455
+ await requirePin(pin, ip);
15456
+ audit.log("owner", enabled ? "system.autostart_on" : "system.autostart_off", { ip });
15457
+ const r = enabled ? await autostart.enable() : await autostart.disable();
15458
+ return { ...r, status: await autostart.status() };
15459
+ }
15460
+ };
14928
15461
  const tls = config.https ? await ensureTlsCert(opts.dataDir, [...config.allowedHosts, ...config.publicUrl ? [new URL(config.publicUrl).hostname] : []]) : void 0;
14929
15462
  const gateway = createGateway({
14930
15463
  ...tls ? { tls } : {},
14931
15464
  connectors: connectorsApi,
14932
15465
  chrome: chromeRemoteApi,
15466
+ system: systemApi,
14933
15467
  video: {
14934
15468
  stream: (id2) => videoHub.streamOf(id2),
14935
15469
  status: async () => videoHub.status() ?? await videoHub.refresh(),
@@ -15338,11 +15872,13 @@ ${url}`);
15338
15872
  conn.send(statusMsg());
15339
15873
  conn.send(uiMsg());
15340
15874
  conn.send({ type: "music.host", state: hostPlayer.state() });
15875
+ if (ducker.level < 1) conn.send({ type: "audio.duck", level: ducker.level });
15341
15876
  const pending = challenges.current();
15342
15877
  if (pending) conn.send({ type: "voice.challenge", id: pending.id, text: pending.text });
15343
15878
  return () => {
15344
15879
  clients.delete(conn);
15345
15880
  musicHub.detach(conn);
15881
+ ducker.drop(duckId(conn));
15346
15882
  };
15347
15883
  },
15348
15884
  onClientMessage: (_session, msg, conn) => {
@@ -15360,6 +15896,9 @@ ${url}`);
15360
15896
  case "voice.interrupt":
15361
15897
  speaker?.interrupt(broadcast);
15362
15898
  break;
15899
+ case "audio.activity":
15900
+ ducker.set(duckId(conn), msg.active);
15901
+ break;
15363
15902
  case "voice.confirm":
15364
15903
  void confirmChallenge(msg.id, msg.pin, conn).catch((e) => audit.log("system", "voice.confirm_error", { message: e.message }));
15365
15904
  break;
@@ -15494,12 +16033,14 @@ ${url}`);
15494
16033
  for (const id2 of gone) broadcast({ type: "stage", command: { cmd: "hide", id: id2 } });
15495
16034
  if (gone.length) for (const follow of stage.settleNow()) broadcast({ type: "stage", command: follow });
15496
16035
  }, opts.expireIntervalMs ?? 1e3);
16036
+ if (opts.system?.checkForUpdates) updater.start();
15497
16037
  const listening = await gateway.listen();
15498
16038
  listenPort = listening.port;
15499
16039
  return listening;
15500
16040
  },
15501
16041
  async stop() {
15502
16042
  stopped = true;
16043
+ updater.stop();
15503
16044
  hostPlayer.dispose();
15504
16045
  videoHub.dispose();
15505
16046
  heartbeat.stop();
@@ -15522,7 +16063,7 @@ ${url}`);
15522
16063
  }
15523
16064
 
15524
16065
  // ../server/src/voice/sidecar.ts
15525
- import { spawn as spawn6 } from "node:child_process";
16066
+ import { spawn as spawn8 } from "node:child_process";
15526
16067
  import { createInterface } from "node:readline";
15527
16068
  var SidecarProcess = class {
15528
16069
  constructor(opts) {
@@ -15559,7 +16100,7 @@ var SidecarProcess = class {
15559
16100
  settled = true;
15560
16101
  reject(new Error(message));
15561
16102
  };
15562
- const child = spawn6(this.opts.command, this.opts.args, {
16103
+ const child = spawn8(this.opts.command, this.opts.args, {
15563
16104
  cwd: this.opts.cwd,
15564
16105
  env: { ...this.opts.env ?? process.env, FRIEND_VOICE_TOKEN: this.opts.token },
15565
16106
  windowsHide: true,
@@ -15658,6 +16199,11 @@ var unit2 = (v) => {
15658
16199
  if (v.trim() === "" || !Number.isFinite(n) || n < 0 || n > 1) throw new Error("Expected a number between 0 and 1");
15659
16200
  return n;
15660
16201
  };
16202
+ var percent = (v) => {
16203
+ const n = Number(v);
16204
+ if (v.trim() === "" || !Number.isFinite(n) || n < 0 || n > 100) throw new Error("Expected a number between 0 and 100");
16205
+ return Math.round(n);
16206
+ };
15661
16207
  var httpUrl = (v) => {
15662
16208
  let u;
15663
16209
  try {
@@ -15702,6 +16248,9 @@ var PARSERS = {
15702
16248
  trustProxy: bool
15703
16249
  };
15704
16250
  var GROUPS = {
16251
+ updates: {
16252
+ auto: bool
16253
+ },
15705
16254
  voice: {
15706
16255
  enabled: bool,
15707
16256
  sttModel: nonEmpty,
@@ -15716,6 +16265,8 @@ var GROUPS = {
15716
16265
  },
15717
16266
  language: (v) => v.trim() === "" ? null : v.trim(),
15718
16267
  speakTyped: bool,
16268
+ duck: bool,
16269
+ duckPercent: percent,
15719
16270
  accept: unit2,
15720
16271
  challenge: unit2
15721
16272
  },
@@ -15773,6 +16324,7 @@ var KEY_HELP = {
15773
16324
  allowedOrigins: "extra web addresses allowed to connect, separated by commas",
15774
16325
  allowedHosts: "extra host names that may reach Friend (for example my-pc), separated by commas",
15775
16326
  trustProxy: "true when Friend sits behind your own reverse proxy",
16327
+ "updates.auto": "true or false: look for a newer Friend when it starts and install it by itself while Friend is not busy (false: only when you run friend upgrade or press Update now)",
15776
16328
  "voice.enabled": "true or false: use speech at all",
15777
16329
  "voice.sttModel": "listening model: tiny, base, small, large-v3-turbo \u2026 (bigger is more accurate and slower; Marathi and far-from-mic speech do best with small or large-v3-turbo)",
15778
16330
  "voice.ttsVoice": "speaking voice (easier: friend voice setup, or Settings)",
@@ -15780,6 +16332,8 @@ var KEY_HELP = {
15780
16332
  "voice.hindiVoice": "the voice that reads Hindi (Devanagari) text: hi_IN-priyamvada-medium, hi_IN-pratham-medium or hi_IN-rohan-medium; empty uses the main voice",
15781
16333
  "voice.marathiVoice": "the voice that reads Marathi (Devanagari) text: mr_IN-google-medium; empty uses the main voice",
15782
16334
  "voice.speakTyped": "true or false: also speak the replies to typed messages",
16335
+ "voice.duck": "true or false: turn music and video down while you talk to Friend and while it speaks",
16336
+ "voice.duckPercent": "0 to 100: how loud music and video are then, as a percent of how loud they were (25 is a quarter)",
15783
16337
  "voice.accept": "0 to 1: a voice at or above this match is you",
15784
16338
  "voice.challenge": "0 to 1: a voice between this and accept must be confirmed with your PIN",
15785
16339
  "ollama.url": "address of the Ollama server, for example http://127.0.0.1:11434",
@@ -15838,12 +16392,12 @@ import { resolve as resolve9 } from "node:path";
15838
16392
  import { WebSocket as WebSocket3 } from "ws";
15839
16393
 
15840
16394
  // ../agent-cli/src/tools/fs.ts
15841
- import { closeSync, mkdirSync as mkdirSync12, openSync, readSync, readdirSync as readdirSync4, statSync as statSync4, writeFileSync as writeFileSync11 } from "node:fs";
15842
- import { dirname as dirname7, join as join19 } from "node:path";
16395
+ import { closeSync, mkdirSync as mkdirSync13, openSync, readSync, readdirSync as readdirSync4, statSync as statSync4, writeFileSync as writeFileSync12 } from "node:fs";
16396
+ import { dirname as dirname8, join as join19 } from "node:path";
15843
16397
 
15844
16398
  // ../agent-cli/src/tools/roots.ts
15845
16399
  import { lstatSync as lstatSync2, realpathSync } from "node:fs";
15846
- import { dirname as dirname6, isAbsolute as isAbsolute2, relative as relative3, resolve as resolve6, sep as sep3 } from "node:path";
16400
+ import { dirname as dirname7, isAbsolute as isAbsolute2, relative as relative3, resolve as resolve6, sep as sep3 } from "node:path";
15847
16401
  var isWin = process.platform === "win32";
15848
16402
  var norm = (p) => isWin ? p.toLowerCase() : p;
15849
16403
  var exists = (p) => {
@@ -15859,18 +16413,18 @@ function realish(abs) {
15859
16413
  let cur = abs;
15860
16414
  const tail2 = [];
15861
16415
  while (!exists(cur)) {
15862
- const parent = dirname6(cur);
16416
+ const parent = dirname7(cur);
15863
16417
  if (parent === cur) return abs;
15864
16418
  tail2.unshift(cur.slice(parent.length).replace(/^[\\/]+/, ""));
15865
16419
  cur = parent;
15866
16420
  }
15867
- let real2;
16421
+ let real3;
15868
16422
  try {
15869
- real2 = realpathSync.native(cur);
16423
+ real3 = realpathSync.native(cur);
15870
16424
  } catch {
15871
16425
  return UNRESOLVABLE + abs;
15872
16426
  }
15873
- return tail2.length ? resolve6(real2, ...tail2) : real2;
16427
+ return tail2.length ? resolve6(real3, ...tail2) : real3;
15874
16428
  }
15875
16429
  function within(root, target) {
15876
16430
  if (target.startsWith(UNRESOLVABLE)) return false;
@@ -15942,8 +16496,8 @@ function fsWrite(args, ctx) {
15942
16496
  if ("error" in g) return { ok: false, error: g.error };
15943
16497
  if (Buffer.byteLength(args.content) > MAX_WRITE) return { ok: false, error: "Content too large (5 MB limit)" };
15944
16498
  try {
15945
- mkdirSync12(dirname7(g.abs), { recursive: true });
15946
- writeFileSync11(g.abs, args.content);
16499
+ mkdirSync13(dirname8(g.abs), { recursive: true });
16500
+ writeFileSync12(g.abs, args.content);
15947
16501
  return { ok: true, output: `Wrote ${Buffer.byteLength(args.content)} bytes to ${g.abs}`, truncated: false };
15948
16502
  } catch (e) {
15949
16503
  return { ok: false, error: e.message };
@@ -15951,7 +16505,7 @@ function fsWrite(args, ctx) {
15951
16505
  }
15952
16506
 
15953
16507
  // ../agent-cli/src/tools/shell.ts
15954
- import { spawn as spawn7, spawnSync } from "node:child_process";
16508
+ import { spawn as spawn9, spawnSync } from "node:child_process";
15955
16509
  function killTree2(child) {
15956
16510
  if (!child.pid) return;
15957
16511
  if (process.platform === "win32") {
@@ -15977,7 +16531,7 @@ function runCommand2(command, cwdArg, ctx, argv) {
15977
16531
  const file = argv?.file ?? (win ? "powershell.exe" : "sh");
15978
16532
  const args = argv?.args ?? (win ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command]);
15979
16533
  return new Promise((resolve13) => {
15980
- const child = spawn7(file, args, { cwd, windowsHide: true, detached: !win, stdio: ["ignore", "pipe", "pipe"] });
16534
+ const child = spawn9(file, args, { cwd, windowsHide: true, detached: !win, stdio: ["ignore", "pipe", "pipe"] });
15981
16535
  let out = "";
15982
16536
  let truncated = false;
15983
16537
  let timedOut = false;
@@ -16179,8 +16733,8 @@ function startDeviceNode(opts) {
16179
16733
 
16180
16734
  // ../agent-cli/src/commands/start.ts
16181
16735
  import { randomBytes as randomBytes10 } from "node:crypto";
16182
- import { existsSync as existsSync18, readFileSync as readFileSync12, writeFileSync as writeFileSync13 } from "node:fs";
16183
- import { dirname as dirname9, join as join21, resolve as resolve8 } from "node:path";
16736
+ import { existsSync as existsSync19, readFileSync as readFileSync13, writeFileSync as writeFileSync14 } from "node:fs";
16737
+ import { dirname as dirname10, join as join21, resolve as resolve8 } from "node:path";
16184
16738
  import { fileURLToPath as fileURLToPath4 } from "node:url";
16185
16739
 
16186
16740
  // ../agent-cli/src/commands/lan.ts
@@ -16285,9 +16839,9 @@ async function lanCommand(args, io2, ifaces = networkInterfaces2) {
16285
16839
  }
16286
16840
 
16287
16841
  // ../agent-cli/src/commands/voice.ts
16288
- import { spawn as spawn8 } from "node:child_process";
16289
- import { existsSync as existsSync17, mkdirSync as mkdirSync13, readFileSync as readFileSync11, writeFileSync as writeFileSync12 } from "node:fs";
16290
- import { dirname as dirname8, join as join20, resolve as resolve7 } from "node:path";
16842
+ import { spawn as spawn10 } from "node:child_process";
16843
+ import { existsSync as existsSync18, mkdirSync as mkdirSync14, readFileSync as readFileSync12, writeFileSync as writeFileSync13 } from "node:fs";
16844
+ import { dirname as dirname9, join as join20, resolve as resolve7 } from "node:path";
16291
16845
  import { fileURLToPath as fileURLToPath3 } from "node:url";
16292
16846
  function voicePaths(dataDir) {
16293
16847
  const home = join20(dataDir, "voice");
@@ -16302,19 +16856,19 @@ function voicePaths(dataDir) {
16302
16856
  }
16303
16857
  function readMarker(dataDir) {
16304
16858
  try {
16305
- return JSON.parse(readFileSync11(voicePaths(dataDir).marker, "utf8"));
16859
+ return JSON.parse(readFileSync12(voicePaths(dataDir).marker, "utf8"));
16306
16860
  } catch {
16307
16861
  return null;
16308
16862
  }
16309
16863
  }
16310
16864
  function findVoiceDir() {
16311
- const here2 = dirname8(fileURLToPath3(import.meta.url));
16865
+ const here2 = dirname9(fileURLToPath3(import.meta.url));
16312
16866
  return firstExistingDir([process.env.FRIEND_VOICE_DIR, resolve7(here2, "voice"), resolve7(here2, "../../../voice")], "friend_voice") ?? resolve7(here2, "voice");
16313
16867
  }
16314
16868
  var QUIET_ENV = { ...process.env, HF_HUB_DISABLE_SYMLINKS_WARNING: "1", PYTHONUTF8: "1" };
16315
16869
  var defaultRunner = (cmd, args, opts) => new Promise((res) => {
16316
16870
  let stdout = "";
16317
- const child = spawn8(cmd, args, { cwd: opts?.cwd, env: QUIET_ENV, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
16871
+ const child = spawn10(cmd, args, { cwd: opts?.cwd, env: QUIET_ENV, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
16318
16872
  const feed = (d) => {
16319
16873
  const s = d.toString();
16320
16874
  stdout += s;
@@ -16399,8 +16953,8 @@ async function voiceSetupCommand(io2, deps = {}, args = []) {
16399
16953
  io2.err("Python 3.10 or newer is required. Install it from https://www.python.org/downloads/ and run this again.");
16400
16954
  return 1;
16401
16955
  }
16402
- mkdirSync13(p.home, { recursive: true });
16403
- if (!existsSync17(p.python)) {
16956
+ mkdirSync14(p.home, { recursive: true });
16957
+ if (!existsSync18(p.python)) {
16404
16958
  io2.out("1/3 Creating a private Python environment\u2026");
16405
16959
  const r = await run2(py.cmd, [...py.args, "-m", "venv", p.venv], { onLine });
16406
16960
  if (r.code !== 0) {
@@ -16434,7 +16988,7 @@ ${tail(dl.stdout)}`);
16434
16988
  return 1;
16435
16989
  }
16436
16990
  const marker = { python: p.python, sttModel: cfg.voice.sttModel, ttsVoice: cfg.voice.ttsVoice, at: Date.now() };
16437
- writeFileSync12(p.marker, JSON.stringify(marker, null, 2));
16991
+ writeFileSync13(p.marker, JSON.stringify(marker, null, 2));
16438
16992
  io2.out("Voice is ready. Start Friend with `friend start`, then hold Space to talk.");
16439
16993
  return 0;
16440
16994
  }
@@ -16450,7 +17004,7 @@ async function voiceStatusCommand(io2) {
16450
17004
  io2.out("Voice engine: not installed. Run: friend voice setup");
16451
17005
  return 0;
16452
17006
  }
16453
- if (!existsSync17(marker.python)) {
17007
+ if (!existsSync18(marker.python)) {
16454
17008
  io2.out("Voice engine: installed, but its Python is missing. Run: friend voice setup");
16455
17009
  return 0;
16456
17010
  }
@@ -16521,9 +17075,9 @@ async function voiceCommand(args, io2) {
16521
17075
 
16522
17076
  // ../agent-cli/src/commands/start.ts
16523
17077
  function findWebDir(explicit) {
16524
- const here2 = dirname9(fileURLToPath4(import.meta.url));
17078
+ const here2 = dirname10(fileURLToPath4(import.meta.url));
16525
17079
  const candidates = [explicit, process.env.FRIEND_WEB_DIR, resolve8(here2, "../../../web/dist"), resolve8(here2, "web")];
16526
- return candidates.find((c) => !!c && existsSync18(join21(c, "index.html")));
17080
+ return candidates.find((c) => !!c && existsSync19(join21(c, "index.html")));
16527
17081
  }
16528
17082
  async function startFriend(opts) {
16529
17083
  const stored = loadConfig(opts.dataDir);
@@ -16548,6 +17102,8 @@ async function startFriend(opts) {
16548
17102
  }) : null;
16549
17103
  const voice2 = sidecar ? new HttpVoiceService({ baseUrl: () => sidecar.baseUrl(), token: secret }) : void 0;
16550
17104
  const app = await createApp({
17105
+ system: { checkForUpdates: true },
17106
+ // looks for a newer Friend after it starts, and updates by itself when that is on
16551
17107
  dataDir: opts.dataDir,
16552
17108
  webDir: findWebDir(opts.webDir),
16553
17109
  voice: voice2,
@@ -16558,16 +17114,16 @@ async function startFriend(opts) {
16558
17114
  });
16559
17115
  const { port } = await app.start();
16560
17116
  const tokenFile = join21(opts.dataDir, "local-device.token");
16561
- let token = existsSync18(tokenFile) ? readFileSync12(tokenFile, "utf8").trim() : "";
17117
+ let token = existsSync19(tokenFile) ? readFileSync13(tokenFile, "utf8").trim() : "";
16562
17118
  if (!token || !app.auth.resolveDevice(token)) {
16563
17119
  token = app.auth.redeemPairCode(app.auth.createPairCode(), "this-pc");
16564
- writeFileSync13(tokenFile, token, { mode: 384 });
17120
+ writeFileSync14(tokenFile, token, { mode: 384 });
16565
17121
  }
16566
17122
  const node = startDeviceNode({
16567
17123
  url: `${app.config.https ? "wss" : "ws"}://127.0.0.1:${port}/device`,
16568
17124
  token,
16569
17125
  roots: app.config.roots,
16570
- ...app.config.https ? { ca: readFileSync12(join21(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
17126
+ ...app.config.https ? { ca: readFileSync13(join21(opts.dataDir, "tls", "cert.pem"), "utf8") } : {}
16571
17127
  // this PC trusts only its own Friend's certificate
16572
17128
  });
16573
17129
  const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
@@ -16795,12 +17351,12 @@ async function memoryCommand(args, io2) {
16795
17351
  }
16796
17352
 
16797
17353
  // ../agent-cli/src/commands/plugin.ts
16798
- import { existsSync as existsSync19, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
16799
- import { dirname as dirname10, join as join22, resolve as resolve10 } from "node:path";
17354
+ import { existsSync as existsSync20, readdirSync as readdirSync5, statSync as statSync5 } from "node:fs";
17355
+ import { dirname as dirname11, join as join22, resolve as resolve10 } from "node:path";
16800
17356
  import { fileURLToPath as fileURLToPath5 } from "node:url";
16801
17357
  var USAGE2 = "Usage: friend plugin catalog | list | install <name|folder> [--yes] | enable <id> | disable <id> | remove <id>";
16802
17358
  function defaultCatalog() {
16803
- const here2 = dirname10(fileURLToPath5(import.meta.url));
17359
+ const here2 = dirname11(fileURLToPath5(import.meta.url));
16804
17360
  return firstExistingDir([process.env.FRIEND_PLUGIN_CATALOG, resolve10(here2, "plugins"), resolve10(here2, "../../../../plugins")]) ?? resolve10(here2, "plugins");
16805
17361
  }
16806
17362
  var count = (n, word2) => `${n} ${word2}${n === 1 ? "" : "s"}`;
@@ -16810,7 +17366,7 @@ async function pluginCommand(args, io2, deps = {}) {
16810
17366
  const flags = rest.filter((a) => a.startsWith("--"));
16811
17367
  const arg = rest.find((a) => !a.startsWith("--"));
16812
17368
  if (sub === "catalog") {
16813
- const dirs = existsSync19(catalogDir) ? readdirSync5(catalogDir).filter((d) => statSync5(join22(catalogDir, d)).isDirectory()) : [];
17369
+ const dirs = existsSync20(catalogDir) ? readdirSync5(catalogDir).filter((d) => statSync5(join22(catalogDir, d)).isDirectory()) : [];
16814
17370
  if (dirs.length === 0) io2.out("The catalog is empty.");
16815
17371
  for (const d of dirs.sort()) {
16816
17372
  const r = inspectPlugin(join22(catalogDir, d));
@@ -16843,8 +17399,8 @@ async function pluginCommand(args, io2, deps = {}) {
16843
17399
  io2.err(USAGE2);
16844
17400
  return 1;
16845
17401
  }
16846
- const inCatalog = !/[\\/]/.test(arg) && existsSync19(join22(catalogDir, arg));
16847
- const source = inCatalog ? join22(catalogDir, arg) : existsSync19(resolve10(arg)) ? resolve10(arg) : null;
17402
+ const inCatalog = !/[\\/]/.test(arg) && existsSync20(join22(catalogDir, arg));
17403
+ const source = inCatalog ? join22(catalogDir, arg) : existsSync20(resolve10(arg)) ? resolve10(arg) : null;
16848
17404
  if (!source) {
16849
17405
  io2.err(`"${arg}" is not in the catalog and there is no such folder. See: friend plugin catalog`);
16850
17406
  return 1;
@@ -16899,7 +17455,7 @@ async function pluginCommand(args, io2, deps = {}) {
16899
17455
 
16900
17456
  // ../agent-cli/src/commands/onboard/index.ts
16901
17457
  import crossSpawn4 from "cross-spawn";
16902
- import { mkdirSync as mkdirSync15 } from "node:fs";
17458
+ import { mkdirSync as mkdirSync16 } from "node:fs";
16903
17459
  import { resolve as resolve11 } from "node:path";
16904
17460
 
16905
17461
  // ../agent-cli/src/commands/ollama.ts
@@ -17222,16 +17778,16 @@ function installPlan(id2, platform, have) {
17222
17778
  }
17223
17779
 
17224
17780
  // ../agent-cli/src/commands/onboard/state.ts
17225
- import { existsSync as existsSync20, mkdirSync as mkdirSync14, readFileSync as readFileSync13, renameSync as renameSync4, rmSync as rmSync9, writeFileSync as writeFileSync14 } from "node:fs";
17226
- import { dirname as dirname11, join as join23 } from "node:path";
17781
+ import { existsSync as existsSync21, mkdirSync as mkdirSync15, readFileSync as readFileSync14, renameSync as renameSync4, rmSync as rmSync10, writeFileSync as writeFileSync15 } from "node:fs";
17782
+ import { dirname as dirname12, join as join23 } from "node:path";
17227
17783
  var FEATURES = ["claude-code", "claude-api", "ollama", "voice", "sandbox"];
17228
17784
  var emptyState = () => ({ version: 1, done: {}, features: [], restart: [] });
17229
17785
  var statePath = (dataDir) => join23(dataDir, "onboarding.json");
17230
17786
  function loadState(dataDir) {
17231
17787
  const f = statePath(dataDir);
17232
- if (!existsSync20(f)) return emptyState();
17788
+ if (!existsSync21(f)) return emptyState();
17233
17789
  try {
17234
- const raw = JSON.parse(readFileSync13(f, "utf8"));
17790
+ const raw = JSON.parse(readFileSync14(f, "utf8"));
17235
17791
  const done = raw.done && typeof raw.done === "object" ? Object.fromEntries(Object.entries(raw.done).filter(([, v]) => typeof v === "string")) : {};
17236
17792
  const features2 = Array.isArray(raw.features) ? raw.features.filter((x) => FEATURES.includes(x)) : [];
17237
17793
  const restart = Array.isArray(raw.restart) ? raw.restart.filter((x) => typeof x === "string") : [];
@@ -17242,13 +17798,13 @@ function loadState(dataDir) {
17242
17798
  }
17243
17799
  function saveState(dataDir, state) {
17244
17800
  const f = statePath(dataDir);
17245
- mkdirSync14(dirname11(f), { recursive: true });
17801
+ mkdirSync15(dirname12(f), { recursive: true });
17246
17802
  const tmp = `${f}.tmp`;
17247
- writeFileSync14(tmp, JSON.stringify(state, null, 2));
17803
+ writeFileSync15(tmp, JSON.stringify(state, null, 2));
17248
17804
  renameSync4(tmp, f);
17249
17805
  }
17250
17806
  function resetState(dataDir) {
17251
- rmSync9(statePath(dataDir), { force: true });
17807
+ rmSync10(statePath(dataDir), { force: true });
17252
17808
  }
17253
17809
 
17254
17810
  // ../agent-cli/src/commands/onboard/ui.ts
@@ -17600,7 +18156,7 @@ async function workspace(c) {
17600
18156
  const answer = await text2(c.ui, current ? "Workspace folder" : "Workspace folder (Enter to skip)", current ?? "");
17601
18157
  if (answer) {
17602
18158
  const abs = resolve11(answer);
17603
- mkdirSync15(abs, { recursive: true });
18159
+ mkdirSync16(abs, { recursive: true });
17604
18160
  cfg.roots = [abs];
17605
18161
  saveConfig(cfg);
17606
18162
  io2.out(`Workspace: ${abs}`);
@@ -17770,19 +18326,54 @@ async function doctorCommand(io2, partial = {}) {
17770
18326
  return requiredMissing ? 1 : 0;
17771
18327
  }
17772
18328
 
18329
+ // ../agent-cli/src/commands/autostart.ts
18330
+ var USAGE3 = "Usage: friend autostart on|off|status";
18331
+ var real = (io2) => new Autostart(realAutostartDeps(io2.dataDir), currentSelf);
18332
+ async function autostartCommand(args, io2, autostart = real(io2)) {
18333
+ switch (args[0]) {
18334
+ case "on":
18335
+ case "off": {
18336
+ const r = await (args[0] === "on" ? autostart.enable() : autostart.disable());
18337
+ if (!r.ok) {
18338
+ io2.err(r.message);
18339
+ return 1;
18340
+ }
18341
+ io2.out(r.message);
18342
+ if (args[0] === "on") io2.out("Turn it off again with: friend autostart off");
18343
+ return 0;
18344
+ }
18345
+ case "status": {
18346
+ const s = await autostart.status();
18347
+ if (!s.supported) {
18348
+ io2.out("Starting Friend by itself is not supported on this kind of computer.");
18349
+ return 0;
18350
+ }
18351
+ io2.out(`Starts by itself when you sign in: ${s.enabled ? "yes" : "no"}${s.enabled ? ` (${s.kind})` : ""}`);
18352
+ if (!s.enabled) io2.out("Turn it on with: friend autostart on");
18353
+ return 0;
18354
+ }
18355
+ case "refresh":
18356
+ await autostart.refresh();
18357
+ return 0;
18358
+ default:
18359
+ io2.err(USAGE3);
18360
+ return 1;
18361
+ }
18362
+ }
18363
+
17773
18364
  // ../agent-cli/src/commands/daemon.ts
17774
- import { spawn as spawn9, spawnSync as spawnSync2 } from "node:child_process";
17775
- import { closeSync as closeSync2, existsSync as existsSync21, mkdirSync as mkdirSync16, openSync as openSync2, readFileSync as readFileSync14, renameSync as renameSync5, rmSync as rmSync10, statSync as statSync6, writeFileSync as writeFileSync15 } from "node:fs";
18365
+ import { spawn as spawn11, spawnSync as spawnSync2 } from "node:child_process";
18366
+ import { closeSync as closeSync2, existsSync as existsSync22, mkdirSync as mkdirSync17, openSync as openSync2, readFileSync as readFileSync15, renameSync as renameSync5, rmSync as rmSync11, statSync as statSync6, writeFileSync as writeFileSync16 } from "node:fs";
17776
18367
  import { connect } from "node:net";
17777
18368
  import { join as join24 } from "node:path";
17778
- var USAGE3 = "Usage: friend daemon start | stop | restart | status";
18369
+ var USAGE4 = "Usage: friend daemon start | stop | restart | status";
17779
18370
  var MAX_LOG_BYTES = 5 * 1024 * 1024;
17780
18371
  var sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
17781
18372
  var realDeps = {
17782
18373
  launch(logFile) {
17783
18374
  const fd = openSync2(logFile, "a");
17784
18375
  try {
17785
- const child = spawn9(process.execPath, [process.argv[1], "start"], { detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
18376
+ const child = spawn11(process.execPath, [process.argv[1], "start"], { detached: true, stdio: ["ignore", fd, fd], windowsHide: true, env: { ...process.env, FRIEND_DAEMON: "1" } });
17786
18377
  child.unref();
17787
18378
  return child.pid;
17788
18379
  } finally {
@@ -17834,7 +18425,7 @@ var pidFile = (dataDir) => join24(dataDir, "friend.pid");
17834
18425
  var logFileOf = (dataDir) => join24(dataDir, "friend.log");
17835
18426
  function readPid(dataDir) {
17836
18427
  try {
17837
- const r = JSON.parse(readFileSync14(pidFile(dataDir), "utf8"));
18428
+ const r = JSON.parse(readFileSync15(pidFile(dataDir), "utf8"));
17838
18429
  return Number.isInteger(r.pid) && r.pid > 0 ? r : null;
17839
18430
  } catch {
17840
18431
  return null;
@@ -17843,7 +18434,7 @@ function readPid(dataDir) {
17843
18434
  function running(dataDir, deps) {
17844
18435
  const r = readPid(dataDir);
17845
18436
  if (r && deps.alive(r.pid)) return r;
17846
- if (r) rmSync10(pidFile(dataDir), { force: true });
18437
+ if (r) rmSync11(pidFile(dataDir), { force: true });
17847
18438
  return null;
17848
18439
  }
17849
18440
  function since(ms2) {
@@ -17865,10 +18456,10 @@ async function start(io2, deps) {
17865
18456
  io2.err(`Port ${cfg.port} is already in use. Friend may be running in a terminal (stop it with Ctrl+C), or another program uses that port (friend lan port N).`);
17866
18457
  return 1;
17867
18458
  }
17868
- mkdirSync16(io2.dataDir, { recursive: true });
18459
+ mkdirSync17(io2.dataDir, { recursive: true });
17869
18460
  const log = logFileOf(io2.dataDir);
17870
18461
  try {
17871
- if (existsSync21(log) && statSync6(log).size > MAX_LOG_BYTES) renameSync5(log, `${log}.old`);
18462
+ if (existsSync22(log) && statSync6(log).size > MAX_LOG_BYTES) renameSync5(log, `${log}.old`);
17872
18463
  } catch {
17873
18464
  }
17874
18465
  const pid = deps.launch(log);
@@ -17876,7 +18467,7 @@ async function start(io2, deps) {
17876
18467
  io2.err("Could not start Friend in the background.");
17877
18468
  return 1;
17878
18469
  }
17879
- writeFileSync15(pidFile(io2.dataDir), JSON.stringify({ pid, startedAt: deps.now() }));
18470
+ writeFileSync16(pidFile(io2.dataDir), JSON.stringify({ pid, startedAt: deps.now() }));
17880
18471
  for (let i = 0; i < 60; i++) {
17881
18472
  if (await deps.portOpen(cfg.port, 300)) {
17882
18473
  io2.out(`Friend is running in the background (pid ${pid}) at ${urls.local}`);
@@ -17887,7 +18478,7 @@ async function start(io2, deps) {
17887
18478
  if (!deps.alive(pid)) break;
17888
18479
  await sleep(250);
17889
18480
  }
17890
- rmSync10(pidFile(io2.dataDir), { force: true });
18481
+ rmSync11(pidFile(io2.dataDir), { force: true });
17891
18482
  io2.err(`Friend did not start. See the log: ${log}`);
17892
18483
  return 1;
17893
18484
  }
@@ -17902,7 +18493,7 @@ async function stop(io2, deps) {
17902
18493
  io2.err(`Could not stop Friend (pid ${r.pid}). End that process yourself.`);
17903
18494
  return 1;
17904
18495
  }
17905
- rmSync10(pidFile(io2.dataDir), { force: true });
18496
+ rmSync11(pidFile(io2.dataDir), { force: true });
17906
18497
  io2.out("Friend stopped.");
17907
18498
  return 0;
17908
18499
  }
@@ -17934,25 +18525,27 @@ async function daemonCommand(args, io2, deps = realDeps) {
17934
18525
  case "status":
17935
18526
  return status(io2, deps);
17936
18527
  default:
17937
- io2.err(USAGE3);
18528
+ io2.err(USAGE4);
17938
18529
  return 1;
17939
18530
  }
17940
18531
  }
17941
18532
 
17942
18533
  // ../agent-cli/src/commands/upgrade.ts
17943
18534
  import crossSpawn5 from "cross-spawn";
17944
- var PACKAGE = "friend-assistant";
17945
- var real = {
17946
- current: () => true ? "0.1.43" : "development",
18535
+ import { appendFileSync } from "node:fs";
18536
+ import { join as join25 } from "node:path";
18537
+ var PACKAGE2 = "friend-assistant";
18538
+ var real2 = {
18539
+ current: () => true ? "0.1.45" : "development",
17947
18540
  latest: () => new Promise((resolve13) => {
17948
- const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
18541
+ const child = crossSpawn5("npm", ["view", PACKAGE2, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
17949
18542
  let out = "";
17950
18543
  child.stdout?.on("data", (d) => out += d.toString());
17951
18544
  child.on("error", () => resolve13(null));
17952
18545
  child.on("close", (code) => resolve13(code === 0 && /^\d+\.\d+\.\d+/.test(out.trim()) ? out.trim().split(/\s+/)[0] : null));
17953
18546
  }),
17954
18547
  install: (version) => new Promise((resolve13) => {
17955
- const child = crossSpawn5("npm", ["install", "-g", `${PACKAGE}@${version}`, "--prefer-online"], { stdio: "inherit" });
18548
+ const child = crossSpawn5("npm", ["install", "-g", `${PACKAGE2}@${version}`, "--prefer-online"], { stdio: "inherit" });
17956
18549
  child.on("error", () => resolve13(1));
17957
18550
  child.on("close", (code) => resolve13(code ?? 1));
17958
18551
  }),
@@ -17961,6 +18554,11 @@ var real = {
17961
18554
  child.on("error", () => resolve13(1));
17962
18555
  child.on("close", (code) => resolve13(code ?? 1));
17963
18556
  }),
18557
+ refreshAutostart: () => new Promise((resolve13) => {
18558
+ const child = crossSpawn5("friend", ["autostart", "refresh"], { stdio: "ignore" });
18559
+ child.on("error", () => resolve13());
18560
+ child.on("close", () => resolve13());
18561
+ }),
17964
18562
  daemon: realDeps
17965
18563
  };
17966
18564
  var newer = (a, b) => {
@@ -17969,12 +18567,26 @@ var newer = (a, b) => {
17969
18567
  for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
17970
18568
  return false;
17971
18569
  };
17972
- async function upgradeCommand(args, io2, deps = real) {
18570
+ async function upgradeCommand(args, io0, deps = real2) {
17973
18571
  const force = args.includes("--force");
17974
- if (args.some((a) => a !== "--force")) {
17975
- io2.err("Usage: friend upgrade [--force]");
18572
+ const auto = args.includes("--auto");
18573
+ if (args.some((a) => a !== "--force" && a !== "--auto")) {
18574
+ io0.err("Usage: friend upgrade [--force] [--auto]");
17976
18575
  return 1;
17977
18576
  }
18577
+ const log = deps.log ?? ((line) => appendFileSync(join25(io0.dataDir, "update.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
18578
+ `));
18579
+ const io2 = auto ? {
18580
+ ...io0,
18581
+ out: (l) => {
18582
+ io0.out(l);
18583
+ log(l);
18584
+ },
18585
+ err: (l) => {
18586
+ io0.err(l);
18587
+ log(l);
18588
+ }
18589
+ } : io0;
17978
18590
  const current = deps.current();
17979
18591
  io2.out(`Installed: ${current}`);
17980
18592
  const latest = await deps.latest();
@@ -17997,7 +18609,7 @@ async function upgradeCommand(args, io2, deps = real) {
17997
18609
  io2.err(`Friend is running in a terminal (port ${cfg.port} is in use). Press Ctrl+C there to stop it, then run: friend upgrade`);
17998
18610
  return 1;
17999
18611
  }
18000
- io2.out(`Installing ${PACKAGE}@${latest}\u2026`);
18612
+ io2.out(`Installing ${PACKAGE2}@${latest}\u2026`);
18001
18613
  const code = await deps.install(latest);
18002
18614
  if (code !== 0) {
18003
18615
  io2.err("The install failed. If it says EBUSY or EPERM, close whatever is using Friend (a terminal running it, a voice process) and run: friend upgrade");
@@ -18005,6 +18617,8 @@ async function upgradeCommand(args, io2, deps = real) {
18005
18617
  return 1;
18006
18618
  }
18007
18619
  io2.out(`Upgraded ${current} \u2192 ${latest}.`);
18620
+ await deps.refreshAutostart?.().catch(() => {
18621
+ });
18008
18622
  if (wasRunning) return deps.startAgain();
18009
18623
  io2.out("Start it with: friend start (or in the background: friend daemon start)");
18010
18624
  return 0;
@@ -18078,7 +18692,7 @@ async function claudeCommand(args, io2, deps = {}) {
18078
18692
  }
18079
18693
 
18080
18694
  // ../agent-cli/src/commands/setup.ts
18081
- import { mkdirSync as mkdirSync17 } from "node:fs";
18695
+ import { mkdirSync as mkdirSync18 } from "node:fs";
18082
18696
  import { resolve as resolve12 } from "node:path";
18083
18697
  async function setupCommand3(io2) {
18084
18698
  const db = openDb(io2.dataDir);
@@ -18104,7 +18718,7 @@ async function setupCommand3(io2) {
18104
18718
  const cfg = loadConfig(io2.dataDir);
18105
18719
  if (workspace2) {
18106
18720
  const abs = resolve12(workspace2);
18107
- mkdirSync17(abs, { recursive: true });
18721
+ mkdirSync18(abs, { recursive: true });
18108
18722
  cfg.roots = [abs];
18109
18723
  }
18110
18724
  if (apiKey) cfg.apiKey = apiKey;
@@ -18169,7 +18783,7 @@ async function statusCommand3(io2) {
18169
18783
  }
18170
18784
 
18171
18785
  // ../agent-cli/src/cli.ts
18172
- var USAGE4 = `Usage: friend <command>
18786
+ var USAGE5 = `Usage: friend <command>
18173
18787
 
18174
18788
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
18175
18789
  doctor check what this PC has and what is missing (changes nothing)
@@ -18179,6 +18793,7 @@ var USAGE4 = `Usage: friend <command>
18179
18793
  start run the gateway and a local device node
18180
18794
  daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
18181
18795
  upgrade [--force] stop the background Friend, install the newest version, start it again
18796
+ autostart on|off|status start Friend by itself when you sign in to this computer (Windows, macOS, Linux)
18182
18797
  pair print a one-time code to connect another PC
18183
18798
  node --url <ws-url> --code <code> [--roots a,b] [--name pc]
18184
18799
  connect this PC to a Friend gateway
@@ -18211,6 +18826,8 @@ async function run(argv, io2) {
18211
18826
  return await daemonCommand(rest, io2);
18212
18827
  case "upgrade":
18213
18828
  return await upgradeCommand(rest, io2);
18829
+ case "autostart":
18830
+ return await autostartCommand(rest, io2);
18214
18831
  case "pair":
18215
18832
  return await pairCommand(io2);
18216
18833
  case "node":
@@ -18226,12 +18843,12 @@ async function run(argv, io2) {
18226
18843
  case "help":
18227
18844
  case "--help":
18228
18845
  case "-h":
18229
- io2.out(USAGE4);
18846
+ io2.out(USAGE5);
18230
18847
  return 0;
18231
18848
  case "version":
18232
18849
  case "--version":
18233
18850
  case "-v":
18234
- io2.out(true ? "0.1.43" : "development");
18851
+ io2.out(true ? "0.1.45" : "development");
18235
18852
  return 0;
18236
18853
  case "lan":
18237
18854
  return await lanCommand(rest, io2);
@@ -18250,7 +18867,7 @@ async function run(argv, io2) {
18250
18867
  case "config":
18251
18868
  return await configCommand(rest, io2);
18252
18869
  default:
18253
- io2.err(USAGE4);
18870
+ io2.err(USAGE5);
18254
18871
  return 1;
18255
18872
  }
18256
18873
  } catch (e) {