friend-assistant 0.1.38 → 0.1.39

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.38",
4
- "builtAt": "2026-10-12T14:21:14.686Z"
3
+ "version": "0.1.39",
4
+ "builtAt": "2026-10-13T01:19:00.862Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -695,8 +695,8 @@ var TOOL_DEFS = [
695
695
  },
696
696
  {
697
697
  name: "video.play",
698
- description: "Play a video or a song from the internet inside Friend: a link to YouTube or almost any video site, or words to search for on YouTube (a title, a channel, a topic). Use this, not the browser: never open the site in Chrome. It plays on the owner's Friend screen (output screen) or in a window on the PC (output host); leave output out to use the owner's choice. start is where to begin, in seconds. Say in a few words what is playing. If Friend is missing a helper program the tool says which one and how to install it: pass that on in plain words.",
699
- input: z4.object({ url: z4.string().max(2e3).optional(), query: z4.string().max(200).optional(), output: z4.enum(["screen", "host"]).optional(), start: z4.number().min(0).max(86400).optional() }).strict(),
698
+ description: "Play a video or a song from the internet inside Friend: a link to YouTube or almost any video site, or words to search for on YouTube (a title, a channel, a topic). Use this, not the browser: never open the site in Chrome. It plays on the owner's Friend screen (output screen) or in a window on the PC (output host); leave output out to use the owner's choice. start is where to begin, in seconds. A YouTube video normally plays in YouTube's own player inside the card; if it will not play there (the owner of the video blocked it from being shown elsewhere), call again with mode stream, which plays it through Friend. Say in a few words what is playing. If Friend is missing a helper program the tool says which one and how to install it: pass that on in plain words.",
699
+ input: z4.object({ url: z4.string().max(2e3).optional(), query: z4.string().max(200).optional(), output: z4.enum(["screen", "host"]).optional(), start: z4.number().min(0).max(86400).optional(), mode: z4.enum(["embed", "stream"]).optional() }).strict(),
700
700
  baseTier: "T1",
701
701
  target: "gateway"
702
702
  },
@@ -1279,6 +1279,7 @@ function commandToAction(c) {
1279
1279
 
1280
1280
  // ../protocol/src/video.ts
1281
1281
  var VIDEO_OUTPUTS = ["screen", "host"];
1282
+ var VIDEO_YOUTUBE = ["embed", "stream"];
1282
1283
  var VIDEO_LIMITS = {
1283
1284
  /** The tallest picture asked for, in pixels (a lower one saves the connection and the PC). */
1284
1285
  maxHeight: { min: 240, max: 1080, def: 720 },
@@ -2012,6 +2013,7 @@ function sandboxError(type, props, requireHtml) {
2012
2013
  }
2013
2014
  var MEDIA_SRC = /^\/api\/media\/[0-9a-f]{32}$/;
2014
2015
  var VIDEO_SRC = /^\/api\/video\/[0-9a-f]{32}$/;
2016
+ var YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
2015
2017
  var NOT_IN_COMPOSITE = /* @__PURE__ */ new Set(["group", "tabs", "sandbox"]);
2016
2018
  var MAX_PARTS = 8;
2017
2019
  var MEDIA_TYPES = /* @__PURE__ */ new Set(["image", "video", "pdf"]);
@@ -2031,6 +2033,11 @@ function partsError(parts, what) {
2031
2033
  function propsError(type, props, requireAll) {
2032
2034
  const sandbox = sandboxError(type, props, requireAll);
2033
2035
  if (sandbox) return sandbox;
2036
+ if (type === "video" && "embed" in props) {
2037
+ if (typeof props.embed !== "string" || !YOUTUBE_ID.test(props.embed)) return "A video's embed must be a YouTube video id.";
2038
+ if ("start" in props && (typeof props.start !== "number" || !Number.isFinite(props.start) || props.start < 0 || props.start > 86400)) return "A video's start must be a number of seconds.";
2039
+ return null;
2040
+ }
2034
2041
  if (MEDIA_TYPES.has(type) && (requireAll || "src" in props)) {
2035
2042
  if (typeof props.src !== "string" || !(MEDIA_SRC.test(props.src) || type === "video" && VIDEO_SRC.test(props.src))) return `An ${type} object needs src from file.present (a file the owner approved), not a web address.`;
2036
2043
  }
@@ -2499,7 +2506,7 @@ Music
2499
2506
 
2500
2507
  Video from the internet
2501
2508
  - When the owner asks to play a video, a song or a clip from YouTube or any website, use video.play: it plays inside Friend (on the screen, in a card with its own controls) or in a window on the PC. Never open the site in the owner's Chrome or any browser (the browser tools, the shell, a link) to play something: that is not what they want. If video.play cannot work (Web video is switched off, a helper is missing), say so and what to do, instead of opening a browser.
2502
- - You can play a video from YouTube or almost any video site with video.play: give it the link the owner gave you (url), or words to search on YouTube (query). It plays on the Friend screen, or in a window on the PC when the owner says "on the PC" or "in a window" (output host). Use video.control to pause, resume, seek (a time in seconds), set the volume or stop, and video.check when a video will not play: it names the helper program that is missing (yt-dlp, ffmpeg or mpv) and the command to install it, which you pass on in plain words. If the tool says Web video is not switched on, tell the owner to turn it on in Plugins, Web video: you cannot do that yourself. Say in a few words what is playing; never read out links. Never say you cannot play videos.
2509
+ - You can play a video from YouTube or almost any video site with video.play: give it the link the owner gave you (url), or words to search on YouTube (query). It plays on the Friend screen, or in a window on the PC when the owner says "on the PC" or "in a window" (output host). Use video.control to pause, resume, seek (a time in seconds), set the volume or stop, and video.check when a video will not play: it names the helper program that is missing (yt-dlp, ffmpeg or mpv) and the command to install it, which you pass on in plain words. If the tool says Web video is not switched on, tell the owner to turn it on in Plugins, Web video: you cannot do that yourself. A YouTube video normally plays in YouTube's own player inside the card; if the owner says it will not play (a video that its owner blocked from being shown elsewhere), call video.play again with mode stream. Say in a few words what is playing; never read out links. Never say you cannot play videos.
2503
2510
 
2504
2511
  Languages
2505
2512
  - Answer in the language the owner speaks to you. They may use English, Hindi or Marathi, or mix them in one sentence. Write Hindi and Marathi in Devanagari script, never in Roman letters, and keep English words, names and technical terms in Latin letters. Your voice reads each script with its own voice, so a mixed sentence like "\u0906\u091C \u0915\u093E weather \u092C\u0939\u0941\u0924 \u0905\u091A\u094D\u091B\u093E \u0939\u0948" is spoken correctly, and Roman-letter Hindi would not be. Keep spoken sentences short and end Hindi and Marathi sentences with \u0964 or a full stop. In Hindi and Marathi verbs change with the speaker's gender: talk about yourself in the gender of your voice, which the language note of each request states (a woman's voice says "\u092E\u0948\u0902 \u0915\u0930 \u0930\u0939\u0940 \u0939\u0942\u0901", never "\u092E\u0948\u0902 \u0915\u0930 \u0930\u0939\u093E \u0939\u0942\u0901").
@@ -3116,7 +3123,8 @@ function sanitizeVideo(raw) {
3116
3123
  allowedSites: normalizeSites(r.allowedSites),
3117
3124
  ytdlp: program(r.ytdlp),
3118
3125
  ffmpeg: program(r.ffmpeg),
3119
- autoInstall: r.autoInstall !== false
3126
+ autoInstall: r.autoInstall !== false,
3127
+ youtube: VIDEO_YOUTUBE.includes(r.youtube) ? r.youtube : "embed"
3120
3128
  };
3121
3129
  }
3122
3130
  function sanitizeChrome(raw) {
@@ -6062,7 +6070,7 @@ function ffmpegArgs(info, o) {
6062
6070
  if (info.transcodeVideo) args.push("-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-vf", `scale=-2:'min(ih,${Math.round(o.maxHeight)})'`);
6063
6071
  else args.push("-c:v", "copy");
6064
6072
  if (info.transcodeAudio) args.push("-c:a", "aac", "-b:a", "128k", "-ac", "2");
6065
- else args.push("-c:a", "copy");
6073
+ else args.push("-c:a", "copy", "-bsf:a", "aac_adtstoasc");
6066
6074
  args.push("-max_muxing_queue_size", "1024", "-t", String(Math.round(o.maxMinutes * 60)), "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1");
6067
6075
  return args;
6068
6076
  }
@@ -6206,6 +6214,66 @@ async function videoStatus(config, deps = {}) {
6206
6214
  return { ytdlp: asStatus(ytdlp), ffmpeg: asStatus(ffmpeg), mpv: asStatus(mpv) };
6207
6215
  }
6208
6216
 
6217
+ // ../server/src/video/youtube.ts
6218
+ var YOUTUBE_ID2 = /^[A-Za-z0-9_-]{11}$/;
6219
+ var HOSTS = /* @__PURE__ */ new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com", "youtu.be"]);
6220
+ function youtubeId(raw) {
6221
+ let u;
6222
+ try {
6223
+ u = new URL(typeof raw === "string" ? raw.trim() : "");
6224
+ } catch {
6225
+ return null;
6226
+ }
6227
+ if (u.protocol !== "https:" && u.protocol !== "http:" || u.username || u.password || u.port) return null;
6228
+ const host = u.hostname.toLowerCase();
6229
+ if (!HOSTS.has(host)) return null;
6230
+ const parts = u.pathname.split("/").filter(Boolean);
6231
+ const first2 = parts[0] ?? "";
6232
+ let id2 = null;
6233
+ if (host === "youtu.be") id2 = first2 || null;
6234
+ else if (first2 === "watch") id2 = u.searchParams.get("v");
6235
+ else if (["shorts", "live", "embed", "v"].includes(first2)) id2 = parts[1] ?? null;
6236
+ return id2 !== null && YOUTUBE_ID2.test(id2) ? id2 : null;
6237
+ }
6238
+ var clean = (v, max) => typeof v === "string" ? v.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : "";
6239
+ async function titleOfYoutube(id2, d = {}) {
6240
+ const fallback = { title: "YouTube video", uploader: "" };
6241
+ const ctl = new AbortController();
6242
+ const timer = setTimeout(() => ctl.abort(), d.timeoutMs ?? 5e3);
6243
+ try {
6244
+ const url = `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${id2}`)}&format=json`;
6245
+ const res = await (d.fetch ?? fetch)(url, { signal: ctl.signal });
6246
+ if (!res.ok) return fallback;
6247
+ const j = await res.json();
6248
+ return { title: clean(j.title, 200) || fallback.title, uploader: clean(j.author_name, 100) };
6249
+ } catch {
6250
+ return fallback;
6251
+ } finally {
6252
+ clearTimeout(timer);
6253
+ }
6254
+ }
6255
+ async function searchYoutube(query, d) {
6256
+ const q = query.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 200);
6257
+ if (!q) return { ok: false, error: "Tell me what to play: a link to a video, or what to search for." };
6258
+ let res;
6259
+ try {
6260
+ res = await (d.run ?? ytRun)(d.ytdlp, ["--ignore-config", "--no-warnings", "--flat-playlist", "--skip-download", "--socket-timeout", "15", "-J", "--", `ytsearch1:${q}`], { timeoutMs: 3e4, maxBytes: 4e6 });
6261
+ } catch {
6262
+ return { ok: false, error: "Friend could not start yt-dlp." };
6263
+ }
6264
+ if (res.code !== 0) return { ok: false, error: sentenceFor(res.err) };
6265
+ let data;
6266
+ try {
6267
+ data = JSON.parse(res.out);
6268
+ } catch {
6269
+ return { ok: false, error: "Friend could not get that video." };
6270
+ }
6271
+ const e = Array.isArray(data.entries) ? data.entries[0] : void 0;
6272
+ if (!e) return { ok: false, error: "Friend did not find a video for that." };
6273
+ if (typeof e.id !== "string" || !YOUTUBE_ID2.test(e.id)) return { ok: false, error: "Friend did not find a video for that." };
6274
+ return { ok: true, id: e.id, title: clean(e.title, 200) || "YouTube video", uploader: clean(e.uploader ?? e.channel, 100), live: e.is_live === true };
6275
+ }
6276
+
6209
6277
  // ../server/src/video/hub.ts
6210
6278
  var TICKET_MS = 10 * 60 * 1e3;
6211
6279
  var CARD = "video-now";
@@ -6294,23 +6362,58 @@ var VideoHub = class {
6294
6362
  this.tokens.set(id2, { info, startSec, expires: now + TTL_MS });
6295
6363
  return { id: CARD, props: { title: info.title, src: `/api/video/${id2}` } };
6296
6364
  }
6365
+ /** Fetches what is missing (when the owner allows it), waiting a while, then says it is still going. */
6366
+ async ensureTools(need, cfg) {
6367
+ const status2 = await this.ensureStatus();
6368
+ if (!need.length || !cfg.autoInstall || !this.d.provisioner) return { ok: true, status: status2 };
6369
+ const job = this.d.provisioner.ensure(need);
6370
+ const first2 = await Promise.race([job.then((r) => ({ r })), new Promise((res) => setTimeout(() => res({ slow: true }), this.d.waitMs ?? 25e3))]);
6371
+ if ("slow" in first2) {
6372
+ void job.then(() => this.refresh()).catch(() => {
6373
+ });
6374
+ return { ok: false, text: "Friend is downloading the video tools it needs (first time only, it can take a minute or two). Ask again in a minute." };
6375
+ }
6376
+ if (!first2.r.ok) return { ok: false, text: first2.r.error };
6377
+ return { ok: true, status: await this.refresh() };
6378
+ }
6379
+ /** A YouTube video in YouTube's own player, inside the card. A link needs nothing; a search needs yt-dlp, for the list of results only. */
6380
+ async playEmbedded(args, linkId, cfg) {
6381
+ const start2 = typeof args.start === "number" && args.start > 0 ? Math.floor(args.start) : 0;
6382
+ let id2;
6383
+ let title2;
6384
+ let uploader;
6385
+ if (linkId) {
6386
+ const v = await vetVideoUrl(args.url, { allowedSites: cfg.allowedSites, ...this.d.resolve ? { resolve: this.d.resolve } : {} });
6387
+ if (!v.ok) return { ok: false, text: v.error };
6388
+ id2 = linkId;
6389
+ ({ title: title2, uploader } = await titleOfYoutube(id2, { fetch: this.d.fetch }));
6390
+ } else {
6391
+ const sites = cfg.allowedSites;
6392
+ if (sites.length && !sites.some((s) => s === "youtube.com" || s.endsWith(".youtube.com"))) return { ok: false, text: `A search uses YouTube, which is not on your allowed list (${sites.join(", ")}).` };
6393
+ const status0 = await this.ensureStatus();
6394
+ const got = await this.ensureTools(this.missing(status0, ["yt-dlp"]), cfg);
6395
+ if (!got.ok) return got;
6396
+ if (!got.status.ytdlp.found) return { ok: false, text: got.status.ytdlp.install ?? "yt-dlp is not found on this PC." };
6397
+ const found = await searchYoutube(args.query, { ytdlp: got.status.ytdlp.path ?? "yt-dlp", run: this.d.run });
6398
+ if (!found.ok) return { ok: false, text: found.error };
6399
+ ({ id: id2, title: title2, uploader } = found);
6400
+ }
6401
+ await this.stopEverything();
6402
+ this.now = { output: "screen", title: title2, uploader, embed: id2 };
6403
+ return { ok: true, text: `Playing "${title2}"${uploader ? ` by ${uploader}` : ""} on your Friend screen.`, show: { id: CARD, props: { title: title2, embed: id2, ...start2 > 0 ? { start: start2 } : {} } } };
6404
+ }
6297
6405
  async play(args) {
6298
6406
  const cfg = this.d.config().video;
6299
6407
  if (!cfg.installed || !cfg.enabled) return { ok: false, text: "Web video is not switched on. The owner can install and turn it on in Plugins, Web video." };
6300
6408
  const output = args.output ?? cfg.output;
6301
- let status2 = await this.ensureStatus();
6302
- const need = this.missing(status2, this.wanted(output, status2));
6303
- if (need.length && cfg.autoInstall && this.d.provisioner) {
6304
- const job = this.d.provisioner.ensure(need);
6305
- const first2 = await Promise.race([job.then((r3) => ({ r: r3 })), new Promise((res) => setTimeout(() => res({ slow: true }), this.d.waitMs ?? 25e3))]);
6306
- if ("slow" in first2) {
6307
- void job.then(() => this.refresh()).catch(() => {
6308
- });
6309
- return { ok: false, text: "Friend is downloading the video tools it needs (first time only, it can take a minute or two). Ask again in a minute." };
6310
- }
6311
- if (!first2.r.ok) return { ok: false, text: first2.r.error };
6312
- status2 = await this.refresh();
6409
+ if (output === "screen" && (args.mode ?? cfg.youtube) === "embed") {
6410
+ const ytId = args.url ? youtubeId(args.url) : null;
6411
+ if (ytId || !args.url && args.query && args.query.trim()) return this.playEmbedded(args, ytId, cfg);
6313
6412
  }
6413
+ let status2 = await this.ensureStatus();
6414
+ const got = await this.ensureTools(this.missing(status2, this.wanted(output, status2)), cfg);
6415
+ if (!got.ok) return got;
6416
+ status2 = got.status;
6314
6417
  if (!status2.ytdlp.found) return { ok: false, text: status2.ytdlp.install ?? "yt-dlp is not found on this PC." };
6315
6418
  const mpvWindow = output === "host" && status2.mpv.found && !!this.d.host;
6316
6419
  const browserWindow = output === "host" && !mpvWindow && !!this.d.openBrowser && !!this.d.windowUrl;
@@ -6392,8 +6495,12 @@ var VideoHub = class {
6392
6495
  return { ok: true, text: args.action === "seek" ? `Jumped to ${Math.round(args.seconds ?? 0)} seconds.` : args.action === "volume" ? `Volume ${Math.round(args.level ?? 50)}.` : args.action === "pause" ? "Paused." : "Playing again." };
6393
6496
  }
6394
6497
  if (args.action === "seek") {
6498
+ const at = Math.max(0, Math.floor(args.seconds ?? 0));
6499
+ if (now.embed) {
6500
+ return { ok: true, text: `Jumped to ${Math.round(args.seconds ?? 0)} seconds.`, show: { id: CARD, props: { title: now.title, embed: now.embed, ...at > 0 ? { start: at } : {} } } };
6501
+ }
6395
6502
  for (const s of this.streams.splice(0)) s.handle.stop();
6396
- const show2 = this.issue(now.info, Math.max(0, Math.floor(args.seconds ?? 0)));
6503
+ const show2 = this.issue(now.info, at);
6397
6504
  return { ok: true, text: `Jumped to ${Math.round(args.seconds ?? 0)} seconds.`, show: show2 };
6398
6505
  }
6399
6506
  return { ok: true, text: "On the Friend screen the video has its own buttons for pause, play and volume: use those." };
@@ -7701,8 +7808,8 @@ function cleanSpeech(text3, max = MAX_SPEECH) {
7701
7808
  }
7702
7809
  var sentences = (s) => s.split(/(?<=[.!?।])\s+/).map((x) => x.trim()).filter(Boolean);
7703
7810
  function shortForVoice(text3) {
7704
- const clean = cleanSpeech(text3);
7705
- const all = sentences(clean);
7811
+ const clean2 = cleanSpeech(text3);
7812
+ const all = sentences(clean2);
7706
7813
  let spoken = all.slice(0, SHORT_SENTENCES).join(" ");
7707
7814
  let truncated = all.length > SHORT_SENTENCES;
7708
7815
  if (spoken.length > SHORT_MAX) {
@@ -8501,7 +8608,7 @@ async function freshToken(source, tokens, refresh, clock2 = Date.now) {
8501
8608
  // ../server/src/music/soundcloud.ts
8502
8609
  var AUTH = "https://secure.soundcloud.com";
8503
8610
  var API2 = "https://api.soundcloud.com";
8504
- var HOSTS = /* @__PURE__ */ new Set(["secure.soundcloud.com", "api.soundcloud.com"]);
8611
+ var HOSTS2 = /* @__PURE__ */ new Set(["secure.soundcloud.com", "api.soundcloud.com"]);
8505
8612
  var ID3 = /^[0-9]{1,20}$/;
8506
8613
  var FALLBACK_TTL_S = 3600;
8507
8614
  var MAX_HOPS3 = 3;
@@ -8555,7 +8662,7 @@ var SoundCloudHub = class {
8555
8662
  this.d.tokens.forget("soundcloud");
8556
8663
  }
8557
8664
  async call(url, init) {
8558
- if (!HOSTS.has(new URL(url).host)) throw new Error("Friend only talks to SoundCloud's own addresses.");
8665
+ if (!HOSTS2.has(new URL(url).host)) throw new Error("Friend only talks to SoundCloud's own addresses.");
8559
8666
  const ctl = new AbortController();
8560
8667
  const timer = setTimeout(() => ctl.abort(), this.d.requestTimeoutMs ?? 8e3);
8561
8668
  try {
@@ -9326,7 +9433,7 @@ var MusicSources = class {
9326
9433
  // ../server/src/music/spotify.ts
9327
9434
  var AUTH2 = "https://accounts.spotify.com";
9328
9435
  var API3 = "https://api.spotify.com";
9329
- var HOSTS2 = /* @__PURE__ */ new Set(["accounts.spotify.com", "api.spotify.com"]);
9436
+ var HOSTS3 = /* @__PURE__ */ new Set(["accounts.spotify.com", "api.spotify.com"]);
9330
9437
  var SCOPES = "user-read-private user-read-email streaming user-modify-playback-state user-read-playback-state playlist-read-private";
9331
9438
  var PLAYLIST_ID = /^[A-Za-z0-9]{1,64}$/;
9332
9439
  var TRACK_ID = /^[A-Za-z0-9]{1,64}$/;
@@ -9372,7 +9479,7 @@ var SpotifyHub = class {
9372
9479
  this.d.tokens.forget("spotify");
9373
9480
  }
9374
9481
  async call(url, init) {
9375
- if (!HOSTS2.has(new URL(url).host)) throw new Error("Friend only talks to Spotify's own addresses.");
9482
+ if (!HOSTS3.has(new URL(url).host)) throw new Error("Friend only talks to Spotify's own addresses.");
9376
9483
  const ctl = new AbortController();
9377
9484
  const timer = setTimeout(() => ctl.abort(), this.d.requestTimeoutMs ?? 8e3);
9378
9485
  try {
@@ -9915,14 +10022,14 @@ function createZerodha(opts) {
9915
10022
  const f = opts.fetch ?? fetch;
9916
10023
  const now = opts.clock ?? Date.now;
9917
10024
  let session = null;
9918
- const clean = (message) => {
10025
+ const clean2 = (message) => {
9919
10026
  let out = message;
9920
10027
  for (const secret of [session?.accessToken, opts.apiSecret, opts.apiKey]) if (secret) out = out.split(secret).join("***");
9921
10028
  return out.slice(0, 200);
9922
10029
  };
9923
10030
  const fail = (status2, body) => {
9924
10031
  const b = rec(body);
9925
- const message = clean(str(b.message) || `Zerodha answered ${status2}`);
10032
+ const message = clean2(str(b.message) || `Zerodha answered ${status2}`);
9926
10033
  if (status2 === 403 || b.error_type === "TokenException") {
9927
10034
  session = null;
9928
10035
  return new BrokerError(message, "session");
@@ -9969,7 +10076,7 @@ function createZerodha(opts) {
9969
10076
  }
9970
10077
  const body = rec(await res.json().catch(() => ({})));
9971
10078
  const token = str(rec(body.data).access_token);
9972
- if (!res.ok || !token) throw new BrokerError(clean(str(body.message) || "Zerodha did not accept the login."), "auth");
10079
+ if (!res.ok || !token) throw new BrokerError(clean2(str(body.message) || "Zerodha did not accept the login."), "auth");
9973
10080
  session = { accessToken: token, expiresAt: nextSixIst(now()) };
9974
10081
  return session;
9975
10082
  }
@@ -10364,13 +10471,13 @@ var Memory = class {
10364
10471
  db;
10365
10472
  clock;
10366
10473
  add(text3, source) {
10367
- const clean = text3.replace(/\s+/g, " ").trim();
10368
- if (!clean) return { ok: false, error: "There is nothing to remember." };
10369
- if (clean.length > MAX_MEMORY_CHARS) return { ok: false, error: `That note is too long (the limit is ${MAX_MEMORY_CHARS} characters).` };
10370
- const same = this.db.prepare("select id from memories where lower(text) = lower(?)").get(clean);
10474
+ const clean2 = text3.replace(/\s+/g, " ").trim();
10475
+ if (!clean2) return { ok: false, error: "There is nothing to remember." };
10476
+ if (clean2.length > MAX_MEMORY_CHARS) return { ok: false, error: `That note is too long (the limit is ${MAX_MEMORY_CHARS} characters).` };
10477
+ const same = this.db.prepare("select id from memories where lower(text) = lower(?)").get(clean2);
10371
10478
  if (same) return { ok: true, id: same.id, duplicate: true };
10372
10479
  if (this.count() >= MAX_MEMORIES) return { ok: false, error: "Memory is full. Forget something first." };
10373
- const info = this.db.prepare("insert into memories (text, source, created_at) values (?, ?, ?)").run(clean, source, this.clock());
10480
+ const info = this.db.prepare("insert into memories (text, source, created_at) values (?, ?, ?)").run(clean2, source, this.clock());
10374
10481
  return { ok: true, id: Number(info.lastInsertRowid), duplicate: false };
10375
10482
  }
10376
10483
  count() {
@@ -12723,6 +12830,7 @@ var SettingsPatch = z11.object({
12723
12830
  maxMinutes: z11.number().int().min(VIDEO_LIMITS.maxMinutes.min).max(VIDEO_LIMITS.maxMinutes.max),
12724
12831
  allowedSites: z11.array(z11.string().trim().max(200)).max(VIDEO_LIMITS.sites.max),
12725
12832
  autoInstall: z11.boolean(),
12833
+ youtube: z11.enum(VIDEO_YOUTUBE),
12726
12834
  ytdlp: z11.string().trim().max(260).refine((v) => programProblem(v) === null, { message: "That is not a program name or path. Use a name like yt-dlp, or a path with no spaces." }),
12727
12835
  ffmpeg: z11.string().trim().max(260).refine((v) => programProblem(v) === null, { message: "That is not a program name or path. Use a name like ffmpeg, or a path with no spaces." })
12728
12836
  }).partial().strict(),
@@ -17690,7 +17798,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
17690
17798
  import crossSpawn5 from "cross-spawn";
17691
17799
  var PACKAGE = "friend-assistant";
17692
17800
  var real = {
17693
- current: () => true ? "0.1.38" : "development",
17801
+ current: () => true ? "0.1.39" : "development",
17694
17802
  latest: () => new Promise((resolve13) => {
17695
17803
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
17696
17804
  let out = "";
@@ -17978,7 +18086,7 @@ async function run(argv, io2) {
17978
18086
  case "version":
17979
18087
  case "--version":
17980
18088
  case "-v":
17981
- io2.out(true ? "0.1.38" : "development");
18089
+ io2.out(true ? "0.1.39" : "development");
17982
18090
  return 0;
17983
18091
  case "lan":
17984
18092
  return await lanCommand(rest, io2);