friend-assistant 0.1.45 → 0.1.47

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
@@ -99,7 +99,7 @@ function clampToSafe(r) {
99
99
  const h = Math.min(Math.max(r[3], 10), sh);
100
100
  const x = Math.min(Math.max(r[0], sx), sx + sw - w);
101
101
  const y = Math.min(Math.max(r[1], sy), sy + sh - h);
102
- const round = (n) => Math.round(n * 100) / 100;
102
+ const round = (n2) => Math.round(n2 * 100) / 100;
103
103
  return [round(x), round(y), round(w), round(h)];
104
104
  }
105
105
 
@@ -940,7 +940,7 @@ var TOOL_DEFS = [
940
940
  target: "gateway"
941
941
  }
942
942
  ];
943
- var clip = (s, n = 300) => s.length > n ? s.slice(0, n) + "\u2026" : s;
943
+ var clip = (s, n2 = 300) => s.length > n2 ? s.slice(0, n2) + "\u2026" : s;
944
944
  var INVISIBLE = /[\u0000-\u0008\u000B\u000C\u000D-\u001F\u007F-\u009F​-‏‪-‮⁠-⁤⁦-⁩]/g;
945
945
  var visible = (s) => s.replace(INVISIBLE, (c) => `\\u{${c.codePointAt(0).toString(16).toUpperCase()}}`);
946
946
  function describeCall(name, args, device) {
@@ -1023,9 +1023,9 @@ ${visible(clip(String(a.text ?? ""), 600))}`;
1023
1023
  case "reports.run":
1024
1024
  return a.send === true ? "Build your portfolio report and send it to your phone" : "Build your portfolio report";
1025
1025
  case "reports.schedule": {
1026
- const label3 = { telegram: "Telegram", whatsapp: "WhatsApp", sms: "SMS" }[String(a.channel)] ?? visible(String(a.channel ?? ""));
1026
+ const label4 = { telegram: "Telegram", whatsapp: "WhatsApp", sms: "SMS" }[String(a.channel)] ?? visible(String(a.channel ?? ""));
1027
1027
  const days = Array.isArray(a.days) ? a.days.map((d) => ["", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][Number(d)] ?? "?").join(", ") : "Mon to Fri";
1028
- return `Schedule the ${visible(String(a.kind ?? ""))} report "${visible(clip(String(a.name ?? ""), 60))}" at ${visible(String(a.time ?? ""))} (${days}). It sends your portfolio figures to ${label3}.`;
1028
+ return `Schedule the ${visible(String(a.kind ?? ""))} report "${visible(clip(String(a.name ?? ""), 60))}" at ${visible(String(a.time ?? ""))} (${days}). It sends your portfolio figures to ${label4}.`;
1029
1029
  }
1030
1030
  case "reports.remove":
1031
1031
  return `Delete the report schedule #${visible(String(a.id ?? ""))}`;
@@ -1114,8 +1114,8 @@ var MAX_LAUNCHER = 12;
1114
1114
  function launcherProblem(raw) {
1115
1115
  const r = raw && typeof raw === "object" ? raw : null;
1116
1116
  if (!r) return "A button needs a label and an action.";
1117
- const label3 = typeof r.label === "string" ? r.label.trim() : "";
1118
- if (label3.length < 1 || label3.length > 24) return "A button label is 1 to 24 characters.";
1117
+ const label4 = typeof r.label === "string" ? r.label.trim() : "";
1118
+ if (label4.length < 1 || label4.length > 24) return "A button label is 1 to 24 characters.";
1119
1119
  const icon = typeof r.icon === "string" ? r.icon.trim() : "";
1120
1120
  if (icon.length < 1 || [...icon].length > 4) return "A button icon is 1 to 4 characters (a symbol or an emoji).";
1121
1121
  const a = r.action && typeof r.action === "object" ? r.action : null;
@@ -1172,7 +1172,7 @@ function shuffled(items, random = Math.random) {
1172
1172
  }
1173
1173
  return a;
1174
1174
  }
1175
- var clamp = (n, min, max) => Math.min(max, Math.max(min, n));
1175
+ var clamp = (n2, min, max) => Math.min(max, Math.max(min, n2));
1176
1176
  var reset = (s, index, playing) => ({ ...s, index, playing, position: 0, duration: 0 });
1177
1177
  function playerReducer(s, a, env = {}) {
1178
1178
  const mix = env.shuffle ?? shuffled;
@@ -1333,8 +1333,8 @@ function formatterFor(locale2) {
1333
1333
  }
1334
1334
  return f;
1335
1335
  }
1336
- function formatMoney(n, o = DEFAULT_MONEY) {
1337
- const value = Number.isFinite(n) ? n : 0;
1336
+ function formatMoney(n2, o = DEFAULT_MONEY) {
1337
+ const value = Number.isFinite(n2) ? n2 : 0;
1338
1338
  const fixed = Math.abs(value).toFixed(2);
1339
1339
  const negative = value < 0 && fixed !== "0.00";
1340
1340
  return `${negative ? "-" : ""}${o.symbol}${formatterFor(o.locale).format(Number(fixed))}`;
@@ -1346,7 +1346,7 @@ var WHY = { rate: "Zerodha's rate limit was hit, try again in a minute", session
1346
1346
  var asOf = (at, zone) => new Intl.DateTimeFormat("en-GB", { timeZone: zone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(at));
1347
1347
  var rows = (items, line) => [...items.slice(0, MAX_ROWS).map(line), ...items.length > MAX_ROWS ? [`...and ${items.length - MAX_ROWS} more`] : []];
1348
1348
  function partText(part, snap, zone, money = DEFAULT_MONEY) {
1349
- const formatInr = (n) => formatMoney(n, money);
1349
+ const formatInr = (n2) => formatMoney(n2, money);
1350
1350
  const section2 = snap[part];
1351
1351
  if (!section2.ok) return `Could not read the ${part}: ${WHY[section2.kind] ?? section2.error.slice(0, 100)}.`;
1352
1352
  const head = `Zerodha ${part} as of ${asOf(snap.at, zone)}:`;
@@ -1378,7 +1378,7 @@ Available ${formatInr(f.available)}, used ${formatInr(f.used)}, total ${formatIn
1378
1378
  import { createHash } from "node:crypto";
1379
1379
 
1380
1380
  // ../server/src/stats.ts
1381
- var clampPct = (n) => Math.min(100, Math.max(0, Math.round(n)));
1381
+ var clampPct = (n2) => Math.min(100, Math.max(0, Math.round(n2)));
1382
1382
  function used(line) {
1383
1383
  const m = line && /([\d.]+) GB free of ([\d.]+) GB/.exec(line);
1384
1384
  if (!m) return null;
@@ -1629,7 +1629,7 @@ function nextSlot(due, step, after) {
1629
1629
  return next > after ? next : due + (Math.floor((after - due) / step) + 1) * step;
1630
1630
  }
1631
1631
  function localIso(d) {
1632
- const p = (n, w = 2) => String(Math.abs(n)).padStart(w, "0");
1632
+ const p = (n2, w = 2) => String(Math.abs(n2)).padStart(w, "0");
1633
1633
  const off = -d.getTimezoneOffset();
1634
1634
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}${off >= 0 ? "+" : "-"}${p(Math.floor(Math.abs(off) / 60))}:${p(Math.abs(off) % 60)}`;
1635
1635
  }
@@ -1667,10 +1667,10 @@ var Tasks = class {
1667
1667
  return rows3.map(toRow);
1668
1668
  }
1669
1669
  done(id2) {
1670
- const row = this.db.prepare(`select ${COLUMNS} from tasks where id = ? and status = 'open'`).get(id2);
1671
- if (!row) return { ok: false, error: "No open task with that number." };
1672
- if (row.repeat && row.due_at !== null) {
1673
- this.db.prepare("update tasks set due_at = ?, fired_at = null where id = ?").run(nextSlot(row.due_at, STEP[row.repeat], this.clock()), id2);
1670
+ const row2 = this.db.prepare(`select ${COLUMNS} from tasks where id = ? and status = 'open'`).get(id2);
1671
+ if (!row2) return { ok: false, error: "No open task with that number." };
1672
+ if (row2.repeat && row2.due_at !== null) {
1673
+ this.db.prepare("update tasks set due_at = ?, fired_at = null where id = ?").run(nextSlot(row2.due_at, STEP[row2.repeat], this.clock()), id2);
1674
1674
  return { ok: true, rescheduled: true };
1675
1675
  }
1676
1676
  this.db.prepare("update tasks set status = 'done' where id = ?").run(id2);
@@ -1686,10 +1686,10 @@ var Tasks = class {
1686
1686
  }
1687
1687
  /** The owner has been told. A repeating task moves to its next slot after now (missed slots are skipped, never replayed). */
1688
1688
  markFired(id2) {
1689
- const row = this.db.prepare(`select ${COLUMNS} from tasks where id = ?`).get(id2);
1690
- if (!row) return;
1691
- if (row.repeat && row.due_at !== null) {
1692
- this.db.prepare("update tasks set due_at = ?, fired_at = null where id = ?").run(nextSlot(row.due_at, STEP[row.repeat], this.clock()), id2);
1689
+ const row2 = this.db.prepare(`select ${COLUMNS} from tasks where id = ?`).get(id2);
1690
+ if (!row2) return;
1691
+ if (row2.repeat && row2.due_at !== null) {
1692
+ this.db.prepare("update tasks set due_at = ?, fired_at = null where id = ?").run(nextSlot(row2.due_at, STEP[row2.repeat], this.clock()), id2);
1693
1693
  } else {
1694
1694
  this.db.prepare("update tasks set fired_at = ? where id = ?").run(this.clock(), id2);
1695
1695
  }
@@ -1974,7 +1974,7 @@ function regionUse(objects) {
1974
1974
  });
1975
1975
  if (hits.length) used2.set(name, hits);
1976
1976
  }
1977
- return { used: used2, free: REGION_NAMES.filter((n) => !used2.has(n)) };
1977
+ return { used: used2, free: REGION_NAMES.filter((n2) => !used2.has(n2)) };
1978
1978
  }
1979
1979
  function describeScreen(scene, presence, entries, now, panels = [], screen = null, view, accent) {
1980
1980
  const open2 = panels.length ? `; the owner has open: ${panels.map((p) => PANEL_TEXT[p]).join(", ")}, so keep clear of them` : "";
@@ -2322,6 +2322,43 @@ function lexicalInsideRoots(path3, roots) {
2322
2322
  });
2323
2323
  }
2324
2324
 
2325
+ // ../server/src/agent/compact.ts
2326
+ var SYSTEM = "You write short notes for an assistant named Friend, so that it can carry on a long conversation after its memory of it is cleared. Write at most 250 words of plain text, with no preamble. Keep what the owner is working on, decisions made, preferences, names, things promised and tasks still open. Leave out greetings, small talk and anything already finished. Do not invent anything.";
2327
+ var MAX_LINE = 1500;
2328
+ var MAX_TEXT = 3e4;
2329
+ var MAX_SUMMARY = 4e3;
2330
+ function conversationText(history) {
2331
+ const lines = [];
2332
+ for (const m of history) {
2333
+ if (m.role === "tool") continue;
2334
+ const t = m.content.flatMap((b) => b.type === "text" ? [b.text] : []).join(" ").trim();
2335
+ if (t) lines.push(`${m.role === "user" ? "Owner" : "Friend"}: ${t.slice(0, MAX_LINE)}`);
2336
+ }
2337
+ return lines.join("\n").slice(-MAX_TEXT);
2338
+ }
2339
+ async function summarize(provider, history, earlier, signal) {
2340
+ const words = conversationText(history);
2341
+ if (!words) return null;
2342
+ const ask = `${earlier ? `Notes from before:
2343
+ ${earlier}
2344
+
2345
+ ` : ""}Conversation:
2346
+ ${words}
2347
+
2348
+ Write the notes now.`;
2349
+ let out = "";
2350
+ try {
2351
+ for await (const ev of provider.stream({ system: SYSTEM, session: "guest", messages: [{ role: "user", content: [{ type: "text", text: ask }] }], tools: [], signal })) {
2352
+ if (ev.type === "text_delta") out += ev.text;
2353
+ else if (ev.type === "error") return null;
2354
+ }
2355
+ } catch {
2356
+ return null;
2357
+ }
2358
+ out = out.trim().slice(0, MAX_SUMMARY);
2359
+ return out || null;
2360
+ }
2361
+
2325
2362
  // ../server/src/agent/history.ts
2326
2363
  var WINDOW = 40;
2327
2364
  var PICTURE_STUB = "[camera picture, not kept]";
@@ -2552,6 +2589,11 @@ var Agent = class {
2552
2589
  abort = null;
2553
2590
  idleJobs = [];
2554
2591
  memberTalk = /* @__PURE__ */ new Map();
2592
+ /** How much the model read for its last call (the size of the conversation it keeps), when it said. */
2593
+ context = 0;
2594
+ /** The notes the last tidy-up left in the model's conversation, so the next one does not lose what is in them. */
2595
+ lastSummary = null;
2596
+ tidyBlockedUntil = 0;
2555
2597
  /** Runs `job` as soon as no request is being answered (right away when idle). */
2556
2598
  whenIdle(job) {
2557
2599
  if (this.busy) this.idleJobs.push(job);
@@ -2560,6 +2602,65 @@ var Agent = class {
2560
2602
  isBusy() {
2561
2603
  return this.busy;
2562
2604
  }
2605
+ /** Tokens the model's own conversation holds (0 when unknown, or right after a fresh start). */
2606
+ contextTokens() {
2607
+ return this.context;
2608
+ }
2609
+ /** Writes notes on the conversation and starts the model's own conversation over from them. */
2610
+ async compact() {
2611
+ return this.whileIdle(() => this.compactNow(new AbortController().signal));
2612
+ }
2613
+ /** Starts the model's own conversation over from nothing. Friend's notes and memory are kept. */
2614
+ async clear() {
2615
+ return this.whileIdle(async () => {
2616
+ await this.deps.provider.resetSession();
2617
+ this.context = 0;
2618
+ this.lastSummary = null;
2619
+ return { ok: true, message: "Started fresh. Friend remembers its notes, but not the earlier conversation." };
2620
+ });
2621
+ }
2622
+ async whileIdle(job) {
2623
+ if (!this.deps.provider.resetSession) return { ok: false, message: "This model does not keep a conversation of its own, so there is nothing to tidy." };
2624
+ if (this.busy) return { ok: false, message: "Friend is busy right now. Try again in a moment." };
2625
+ this.busy = true;
2626
+ try {
2627
+ return await job();
2628
+ } finally {
2629
+ this.busy = false;
2630
+ for (const j of this.idleJobs.splice(0)) {
2631
+ try {
2632
+ j();
2633
+ } catch (e) {
2634
+ this.deps.audit.log("system", "agent.idle_job_failed", { message: e.message });
2635
+ }
2636
+ }
2637
+ }
2638
+ }
2639
+ async compactNow(signal) {
2640
+ const d = this.deps;
2641
+ const history = loadHistory(d.db);
2642
+ if (!conversationText(history)) return { ok: false, message: "There is nothing to compact yet." };
2643
+ const summary2 = await summarize(d.provider, history, this.lastSummary, signal);
2644
+ if (!summary2) return { ok: false, message: "Could not write the notes just now, so nothing was changed." };
2645
+ await d.provider.resetSession(summary2);
2646
+ this.lastSummary = summary2;
2647
+ this.context = 0;
2648
+ return { ok: true, message: "Done. Friend wrote notes on the conversation and started a fresh one from them." };
2649
+ }
2650
+ /** Before an answer: if the model's own conversation has grown too big, tidy it first. A failure only means trying again later. */
2651
+ async tidyIfBig(emit, signal) {
2652
+ const d = this.deps;
2653
+ const s = d.session?.auto();
2654
+ if (!s?.on || this.context <= s.limit || !d.provider.resetSession) return;
2655
+ const now = d.tasks?.now() ?? Date.now();
2656
+ if (now < this.tidyBlockedUntil) return;
2657
+ emit({ type: "activity", text: "Tidying up my memory" });
2658
+ const r = await this.compactNow(signal).catch(() => ({ ok: false, message: "The notes could not be written." }));
2659
+ if (!r.ok) {
2660
+ this.tidyBlockedUntil = now + 10 * 6e4;
2661
+ d.audit.log("system", "session.tidy_failed", { message: r.message });
2662
+ } else d.audit.log("system", "session.tidied", { auto: true });
2663
+ }
2563
2664
  /** Stop the running turn (used by emergency lock). */
2564
2665
  cancel() {
2565
2666
  this.abort?.abort();
@@ -2572,6 +2673,8 @@ var Agent = class {
2572
2673
  return;
2573
2674
  }
2574
2675
  this.busy = true;
2676
+ const spent = { input: 0, output: 0 };
2677
+ let reported = 0;
2575
2678
  this.scenesThisTurn = 0;
2576
2679
  const stage = (cmd) => {
2577
2680
  d.stage.apply(cmd);
@@ -2592,6 +2695,7 @@ var Agent = class {
2592
2695
  let lastAnswer = "";
2593
2696
  try {
2594
2697
  if (!isolated) repairDangling(d.db);
2698
+ if (!isolated) await this.tidyIfBig(emit, abort.signal);
2595
2699
  const userMsg = { role: "user", content: [{ type: "text", text: text3 }] };
2596
2700
  keep(userMsg);
2597
2701
  const earlier = member ? earlierOf(this.memberTalk.get(ctx.person.id), d.tasks?.now() ?? Date.now()) : [];
@@ -2631,7 +2735,11 @@ var Agent = class {
2631
2735
  languageNote(text3, ctx.spoken, voiceGender(devanagariLanguage(text3) === "mr" || ctx.spoken?.toLowerCase().startsWith("mr") ? "mr" : "hi", { main: d.config.voice.ttsVoice, hindi: d.config.voice.hindiVoice || void 0, marathi: d.config.voice.marathiVoice || void 0 }))
2632
2736
  );
2633
2737
  for await (const ev of d.provider.stream({ system, ...context ? { context } : {}, session: isolated ? "guest" : "owner", messages, tools, signal: abort.signal })) {
2634
- if (ev.type === "text_delta") {
2738
+ if (ev.type === "usage") {
2739
+ spent.input += ev.input;
2740
+ spent.output += ev.output;
2741
+ if (ev.context) reported = ev.context;
2742
+ } else if (ev.type === "text_delta") {
2635
2743
  if (!assistantText) {
2636
2744
  stage({ cmd: "presence", state: "speaking" });
2637
2745
  emit({ type: "activity", text: "Answering" });
@@ -2691,6 +2799,8 @@ var Agent = class {
2691
2799
  d.audit.log("system", "agent.error", { message: e.message });
2692
2800
  emit({ type: "caption.delta", text: "Sorry, something went wrong on my side." });
2693
2801
  } finally {
2802
+ if (reported > 0 && !isolated) this.context = reported;
2803
+ if (d.usage && (spent.input > 0 || spent.output > 0)) d.usage({ source: ctx.source ?? "other", ...spent });
2694
2804
  if (member && lastAnswer) {
2695
2805
  const lines = [...earlierOf(this.memberTalk.get(ctx.person.id), d.tasks?.now() ?? Date.now()), { role: "user", content: [{ type: "text", text: text3 }] }, { role: "assistant", content: [{ type: "text", text: lastAnswer }] }];
2696
2806
  this.memberTalk.set(ctx.person.id, { at: d.tasks?.now() ?? Date.now(), lines: lines.slice(-MEMBER_MEMORY_MSGS) });
@@ -3213,8 +3323,8 @@ function sanitizeAlexa(raw) {
3213
3323
  const users = (Array.isArray(r.allowedUsers) ? r.allowedUsers : []).flatMap((u) => {
3214
3324
  const x = u && typeof u === "object" ? u : {};
3215
3325
  if (typeof x.id !== "string" || !AMAZON_USER.test(x.id)) return [];
3216
- const label3 = typeof x.name === "string" ? x.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "";
3217
- return [{ id: x.id, name: label3 || "Echo", addedAt: typeof x.addedAt === "number" && Number.isFinite(x.addedAt) ? x.addedAt : 0 }];
3326
+ const label4 = typeof x.name === "string" ? x.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "";
3327
+ return [{ id: x.id, name: label4 || "Echo", addedAt: typeof x.addedAt === "number" && Number.isFinite(x.addedAt) ? x.addedAt : 0 }];
3218
3328
  });
3219
3329
  const num3 = (v, o, whole = true) => typeof v === "number" && Number.isFinite(v) ? Math.min(o.max, Math.max(o.min, whole ? Math.round(v) : Math.round(v * 10) / 10)) : o.def;
3220
3330
  const locales = [...new Set((Array.isArray(r.locales) ? r.locales : []).filter((l) => ALEXA_LOCALES.includes(l)))];
@@ -3347,6 +3457,12 @@ function sanitizeLook(raw) {
3347
3457
  };
3348
3458
  }
3349
3459
  var DEFAULT_OLLAMA = { url: "http://127.0.0.1:11434", model: "qwen2.5:7b", contextTokens: 8192, vision: false };
3460
+ var MIN_COMPACT_TOKENS = 5e4;
3461
+ var MAX_COMPACT_TOKENS = 8e5;
3462
+ var sanitizeSession = (s) => ({
3463
+ autoCompact: typeof s?.autoCompact === "boolean" ? s.autoCompact : true,
3464
+ compactAtTokens: typeof s?.compactAtTokens === "number" && Number.isFinite(s.compactAtTokens) ? Math.min(MAX_COMPACT_TOKENS, Math.max(MIN_COMPACT_TOKENS, Math.round(s.compactAtTokens))) : 1e5
3465
+ });
3350
3466
  var sanitizeUpdates = (u) => ({ auto: typeof u?.auto === "boolean" ? u.auto : true });
3351
3467
  var DEFAULT_VOICE = {
3352
3468
  enabled: true,
@@ -3362,7 +3478,7 @@ var DEFAULT_VOICE = {
3362
3478
  duckPercent: 25
3363
3479
  };
3364
3480
  function sanitizeVoice(v) {
3365
- const ok = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
3481
+ const ok = (n2) => typeof n2 === "number" && Number.isFinite(n2) && n2 >= 0 && n2 <= 1;
3366
3482
  const percent2 = typeof v.duckPercent === "number" && Number.isFinite(v.duckPercent) ? Math.max(0, Math.min(100, Math.round(v.duckPercent))) : DEFAULT_VOICE.duckPercent;
3367
3483
  const base = { ...v, duck: typeof v.duck === "boolean" ? v.duck : DEFAULT_VOICE.duck, duckPercent: percent2 };
3368
3484
  if (ok(v.accept) && ok(v.challenge) && v.accept > v.challenge) return base;
@@ -3414,6 +3530,7 @@ var defaults = (dataDir) => ({
3414
3530
  claudeCode: { ...DEFAULT_CLAUDE_CODE },
3415
3531
  voice: { ...DEFAULT_VOICE },
3416
3532
  updates: sanitizeUpdates(void 0),
3533
+ session: sanitizeSession(void 0),
3417
3534
  view: "companion",
3418
3535
  look: { ...DEFAULT_LOOK },
3419
3536
  ownerName: "",
@@ -3461,6 +3578,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3461
3578
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
3462
3579
  voice: sanitizeVoice({ ...DEFAULT_VOICE, ...stored.voice }),
3463
3580
  updates: sanitizeUpdates(stored.updates),
3581
+ session: sanitizeSession(stored.session),
3464
3582
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
3465
3583
  look: sanitizeLook(stored.look),
3466
3584
  ownerName: typeof stored.ownerName === "string" ? stored.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "",
@@ -3557,7 +3675,7 @@ function integrationViews(config, hub, brokers, alexa, spotify, soundcloud, audi
3557
3675
  const vdNeeds = () => {
3558
3676
  if (!vs) return [];
3559
3677
  const need = [{ name: "yt-dlp", h: vs.ytdlp }, ...vdMpvWindow ? [] : [{ name: "ffmpeg", h: vs.ffmpeg }]];
3560
- return need.filter((n) => !n.h.found).map((n) => ({ name: n.name, ...n.h.install ? { install: n.h.install } : {} }));
3678
+ return need.filter((n2) => !n2.h.found).map((n2) => ({ name: n2.name, ...n2.h.install ? { install: n2.h.install } : {} }));
3561
3679
  };
3562
3680
  const vdSteps = [
3563
3681
  { text: "Friend gets the helper programs (yt-dlp and ffmpeg) by itself when you turn the plugin on: nothing to install.", done: !!vs && vdNeeds().length === 0 },
@@ -3897,7 +4015,7 @@ function inspectPlugin(dir) {
3897
4015
  }
3898
4016
  const manifest = parsed.data;
3899
4017
  const seen = (names, kind) => {
3900
- const dup2 = names.find((n, i) => names.indexOf(n) !== i);
4018
+ const dup2 = names.find((n2, i) => names.indexOf(n2) !== i);
3901
4019
  return dup2 ? `The ${kind} name "${dup2}" is used twice.` : null;
3902
4020
  };
3903
4021
  const dup = seen(manifest.skills.map((s) => s.name), "skill") ?? seen(manifest.tools.map((t) => t.name), "tool");
@@ -4317,9 +4435,9 @@ var ConnectorStore = class {
4317
4435
  return true;
4318
4436
  }
4319
4437
  remove(id2) {
4320
- const n = this.items.length;
4438
+ const n2 = this.items.length;
4321
4439
  this.items = this.items.filter((i) => i.id !== id2);
4322
- if (this.items.length === n) return false;
4440
+ if (this.items.length === n2) return false;
4323
4441
  this.save();
4324
4442
  return true;
4325
4443
  }
@@ -4339,10 +4457,10 @@ var hasSecrets = (c) => c.kind === "stdio" ? Object.keys(c.env).length > 0 : Obj
4339
4457
  var MAX_RESULT_BYTES = 1024 * 1024;
4340
4458
  var MAX_TOOL_PART = 28;
4341
4459
  function friendlyToolName(original, taken) {
4342
- let n = original.replace(/[^A-Za-z0-9_-]/g, "-").replace(/_{2,}/g, "_").replace(/^[-_]+/, "") || "tool";
4343
- if (n.length > MAX_TOOL_PART) n = `${n.slice(0, MAX_TOOL_PART - 7)}_${createHash2("sha1").update(original).digest("hex").slice(0, 5)}`;
4344
- let out = n;
4345
- for (let i = 2; taken.has(out); i++) out = `${n.slice(0, MAX_TOOL_PART - 3)}_${i}`;
4460
+ let n2 = original.replace(/[^A-Za-z0-9_-]/g, "-").replace(/_{2,}/g, "_").replace(/^[-_]+/, "") || "tool";
4461
+ if (n2.length > MAX_TOOL_PART) n2 = `${n2.slice(0, MAX_TOOL_PART - 7)}_${createHash2("sha1").update(original).digest("hex").slice(0, 5)}`;
4462
+ let out = n2;
4463
+ for (let i = 2; taken.has(out); i++) out = `${n2.slice(0, MAX_TOOL_PART - 3)}_${i}`;
4346
4464
  taken.add(out);
4347
4465
  return out;
4348
4466
  }
@@ -4978,10 +5096,10 @@ import { join as join9 } from "node:path";
4978
5096
  import { deflateRawSync } from "node:zlib";
4979
5097
  var TABLE = (() => {
4980
5098
  const t = new Uint32Array(256);
4981
- for (let n = 0; n < 256; n++) {
4982
- let c = n;
5099
+ for (let n2 = 0; n2 < 256; n2++) {
5100
+ let c = n2;
4983
5101
  for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
4984
- t[n] = c >>> 0;
5102
+ t[n2] = c >>> 0;
4985
5103
  }
4986
5104
  return t;
4987
5105
  })();
@@ -5544,8 +5662,8 @@ var AudiusHub = class {
5544
5662
  };
5545
5663
  }
5546
5664
  async search(query, limit) {
5547
- const n = Math.max(1, Math.min(50, Math.floor(limit)));
5548
- const j = await this.api("/v1/tracks/search", { query: query.slice(0, 200), limit: String(n) });
5665
+ const n2 = Math.max(1, Math.min(50, Math.floor(limit)));
5666
+ const j = await this.api("/v1/tracks/search", { query: query.slice(0, 200), limit: String(n2) });
5549
5667
  return (Array.isArray(j) ? j : []).flatMap((t) => this.of(t) ?? []);
5550
5668
  }
5551
5669
  async playlists(_limit) {
@@ -5578,7 +5696,7 @@ var AudiusHub = class {
5578
5696
  }
5579
5697
  }
5580
5698
  /** Fetches the audio from a checked address, following at most three redirects and only to checked addresses. */
5581
- async fetchAudio(address, range, signal) {
5699
+ async fetchAudio(address, range2, signal) {
5582
5700
  let next = address;
5583
5701
  for (let hop = 0; hop <= MAX_HOPS; hop++) {
5584
5702
  if (!isAudiusAddress(next, hop > 0) || !await this.publicName(next)) return { ok: false, error: "Audius sent the audio somewhere Friend will not follow." };
@@ -5588,7 +5706,7 @@ var AudiusHub = class {
5588
5706
  const timer = setTimeout(() => ctl.abort(), this.d.requestTimeoutMs ?? 8e3);
5589
5707
  const stop2 = signal ? AbortSignal.any([ctl.signal, signal]) : ctl.signal;
5590
5708
  try {
5591
- res = await (this.d.fetch ?? fetch)(next, { redirect: "manual", headers: range ? { range } : {}, signal: stop2 });
5709
+ res = await (this.d.fetch ?? fetch)(next, { redirect: "manual", headers: range2 ? { range: range2 } : {}, signal: stop2 });
5592
5710
  } finally {
5593
5711
  clearTimeout(timer);
5594
5712
  }
@@ -5842,14 +5960,14 @@ var zipExtract = async (archive, plan, dest) => {
5842
5960
  await fh.read(tail2, 0, tailLen, size - tailLen);
5843
5961
  const eocd = tail2.lastIndexOf(Buffer.from([80, 75, 5, 6]));
5844
5962
  if (eocd < 0) throw new Error("not a zip file");
5845
- const count2 = tail2.readUInt16LE(eocd + 10);
5963
+ const count3 = tail2.readUInt16LE(eocd + 10);
5846
5964
  const cdSize = tail2.readUInt32LE(eocd + 12);
5847
5965
  const cdOffset = tail2.readUInt32LE(eocd + 16);
5848
5966
  if (cdOffset === 4294967295 || cdSize === 4294967295) throw new Error("zip64 archives are not supported");
5849
5967
  const cd = Buffer.alloc(cdSize);
5850
5968
  await fh.read(cd, 0, cdSize, cdOffset);
5851
5969
  let p = 0;
5852
- for (let i = 0; i < count2 && p + 46 <= cd.length; i++) {
5970
+ for (let i = 0; i < count3 && p + 46 <= cd.length; i++) {
5853
5971
  if (cd.readUInt32LE(p) !== 33639248) break;
5854
5972
  const method = cd.readUInt16LE(p + 10);
5855
5973
  const csize = cd.readUInt32LE(p + 20);
@@ -6395,7 +6513,7 @@ var VideoHub = class {
6395
6513
  return this.tokens.has(id2);
6396
6514
  }
6397
6515
  missing(status2, names) {
6398
- return names.filter((n) => !(n === "yt-dlp" ? status2.ytdlp : status2.ffmpeg).found);
6516
+ return names.filter((n2) => !(n2 === "yt-dlp" ? status2.ytdlp : status2.ffmpeg).found);
6399
6517
  }
6400
6518
  /** Downloads what the plugin needs (if the owner allows it) and looks again. Called when the plugin is installed or switched on. */
6401
6519
  async prepare() {
@@ -6598,9 +6716,9 @@ var VideoHub = class {
6598
6716
  return { ok: true, text: "On the Friend screen the video has its own buttons for pause, play and volume: use those." };
6599
6717
  }
6600
6718
  nowPlaying() {
6601
- const n = this.now;
6602
- if (!n) return "Nothing is playing.";
6603
- return `Playing "${n.title}"${n.uploader ? ` by ${n.uploader}` : ""} ${n.output === "host" ? n.via === "browser" ? "in a browser window on this PC" : "in a window on this PC" : "on the Friend screen"}.`;
6719
+ const n2 = this.now;
6720
+ if (!n2) return "Nothing is playing.";
6721
+ return `Playing "${n2.title}"${n2.uploader ? ` by ${n2.uploader}` : ""} ${n2.output === "host" ? n2.via === "browser" ? "in a browser window on this PC" : "in a window on this PC" : "on the Friend screen"}.`;
6604
6722
  }
6605
6723
  /** What is found and what is missing, in words, for the assistant to pass on. */
6606
6724
  async check() {
@@ -6769,8 +6887,8 @@ var Auth = class _Auth {
6769
6887
  }
6770
6888
  async login(password, ip) {
6771
6889
  const succeeded = this.begin("login", ip);
6772
- const row = this.db.prepare("select password_hash h from owner where id = 1").get();
6773
- const ok = row ? await verifySecret(password, row.h) : false;
6890
+ const row2 = this.db.prepare("select password_hash h from owner where id = 1").get();
6891
+ const ok = row2 ? await verifySecret(password, row2.h) : false;
6774
6892
  if (!ok) {
6775
6893
  this.audit.log("anonymous", "login.fail", { ip });
6776
6894
  throw new Error("Invalid credentials");
@@ -6801,8 +6919,8 @@ var Auth = class _Auth {
6801
6919
  }
6802
6920
  async verifyPin(pin, ip) {
6803
6921
  const succeeded = this.begin("pin", ip);
6804
- const row = this.db.prepare("select pin_hash h from owner where id = 1").get();
6805
- const ok = row ? await verifySecret(pin, row.h) : false;
6922
+ const row2 = this.db.prepare("select pin_hash h from owner where id = 1").get();
6923
+ const ok = row2 ? await verifySecret(pin, row2.h) : false;
6806
6924
  if (ok) succeeded();
6807
6925
  else this.audit.log("owner", "pin.fail", { ip });
6808
6926
  return ok;
@@ -6826,8 +6944,8 @@ var Auth = class _Auth {
6826
6944
  const problem = _Auth.checkNewSecrets(input);
6827
6945
  if (problem) throw new Error(problem);
6828
6946
  const succeeded = this.begin("login", ip);
6829
- const row = this.db.prepare("select password_hash h from owner where id = 1").get();
6830
- const ok = row ? await verifySecret(input.current, row.h) : false;
6947
+ const row2 = this.db.prepare("select password_hash h from owner where id = 1").get();
6948
+ const ok = row2 ? await verifySecret(input.current, row2.h) : false;
6831
6949
  if (!ok) {
6832
6950
  this.audit.log("owner", "secrets.fail", { ip });
6833
6951
  throw new Error("Invalid credentials");
@@ -7119,11 +7237,11 @@ var MAX_SEND = 100 * 1024 * 1024;
7119
7237
  var MAX_IMAGE = 40 * 1024 * 1024;
7120
7238
  var MAX_VIDEO = 8 * 1024 * 1024 * 1024;
7121
7239
  var MAX_PDF = 300 * 1024 * 1024;
7122
- var MAX_TEXT = 2e5;
7240
+ var MAX_TEXT2 = 2e5;
7123
7241
  var MAX_CSV_ROWS = 200;
7124
7242
  function parseCsv(text3) {
7125
7243
  const rows3 = [];
7126
- let row = [];
7244
+ let row2 = [];
7127
7245
  let cell = "";
7128
7246
  let quoted2 = false;
7129
7247
  for (let i = 0; i < text3.length; i++) {
@@ -7136,19 +7254,19 @@ function parseCsv(text3) {
7136
7254
  else cell += ch;
7137
7255
  } else if (ch === '"') quoted2 = true;
7138
7256
  else if (ch === ",") {
7139
- row.push(cell);
7257
+ row2.push(cell);
7140
7258
  cell = "";
7141
7259
  } else if (ch === "\n" || ch === "\r") {
7142
7260
  if (ch === "\r" && text3[i + 1] === "\n") i++;
7143
- row.push(cell);
7261
+ row2.push(cell);
7144
7262
  cell = "";
7145
- if (row.some((c) => c !== "")) rows3.push(row);
7146
- row = [];
7263
+ if (row2.some((c) => c !== "")) rows3.push(row2);
7264
+ row2 = [];
7147
7265
  if (rows3.length > MAX_CSV_ROWS + 1) return rows3;
7148
7266
  } else cell += ch;
7149
7267
  }
7150
- row.push(cell);
7151
- if (row.some((c) => c !== "")) rows3.push(row);
7268
+ row2.push(cell);
7269
+ if (row2.some((c) => c !== "")) rows3.push(row2);
7152
7270
  return rows3;
7153
7271
  }
7154
7272
  var insideOf = (dir, path3) => {
@@ -7218,48 +7336,48 @@ var MediaStore = class {
7218
7336
  if (!stat2 || !stat2.isFile()) return { ok: false, error: "That path is not a file." };
7219
7337
  const name = basename(real3);
7220
7338
  const ext = extname(real3).slice(1).toLowerCase();
7221
- const label3 = title2?.trim() || name;
7339
+ const label4 = title2?.trim() || name;
7222
7340
  const tooBig = (limit, what) => stat2.size > limit ? { ok: false, error: `That ${what} is too large to show (${Math.round(stat2.size / 1048576)} MB).` } : null;
7223
7341
  if (IMAGE[ext]) {
7224
7342
  const big = tooBig(MAX_IMAGE, "image");
7225
7343
  if (big) return big;
7226
7344
  const id2 = this.grant(real3, IMAGE[ext], stat2.size);
7227
- return { ok: true, type: "image", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing the image ${name}.` };
7345
+ return { ok: true, type: "image", props: { title: label4, src: `/api/media/${id2}` }, summary: `Showing the image ${name}.` };
7228
7346
  }
7229
7347
  if (VIDEO[ext]) {
7230
7348
  const big = tooBig(MAX_VIDEO, "file");
7231
7349
  if (big) return big;
7232
7350
  const id2 = this.grant(real3, VIDEO[ext], stat2.size);
7233
- return { ok: true, type: "video", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing ${name}.` };
7351
+ return { ok: true, type: "video", props: { title: label4, src: `/api/media/${id2}` }, summary: `Showing ${name}.` };
7234
7352
  }
7235
7353
  if (ext === "pdf") {
7236
7354
  const big = tooBig(MAX_PDF, "PDF");
7237
7355
  if (big) return big;
7238
7356
  const id2 = this.grant(real3, "application/pdf", stat2.size);
7239
- return { ok: true, type: "pdf", props: { title: label3, src: `/api/media/${id2}` }, summary: `Showing the PDF ${name}.` };
7357
+ return { ok: true, type: "pdf", props: { title: label4, src: `/api/media/${id2}` }, summary: `Showing the PDF ${name}.` };
7240
7358
  }
7241
7359
  if (MARKDOWN.has(ext) || ext === "csv" || CODE_LANG[ext]) {
7242
7360
  const handle = await fs.open(real3, "r");
7243
7361
  let text3;
7244
7362
  try {
7245
- const buf = Buffer.alloc(Math.min(stat2.size, MAX_TEXT));
7363
+ const buf = Buffer.alloc(Math.min(stat2.size, MAX_TEXT2));
7246
7364
  await handle.read(buf, 0, buf.length, 0);
7247
7365
  text3 = buf.toString("utf8");
7248
7366
  } finally {
7249
7367
  await handle.close();
7250
7368
  }
7251
7369
  if (text3.includes("\0")) return { ok: false, error: "That file is not text, so it cannot be shown." };
7252
- const cut = stat2.size > MAX_TEXT;
7370
+ const cut = stat2.size > MAX_TEXT2;
7253
7371
  const note = cut ? `
7254
7372
 
7255
- [Only the first ${MAX_TEXT} characters are shown.]` : "";
7256
- if (MARKDOWN.has(ext)) return { ok: true, type: "markdown", props: { title: label3, text: text3 + note }, summary: `Showing ${name}.${cut ? " It is long, so only the start is shown." : ""}` };
7373
+ [Only the first ${MAX_TEXT2} characters are shown.]` : "";
7374
+ if (MARKDOWN.has(ext)) return { ok: true, type: "markdown", props: { title: label4, text: text3 + note }, summary: `Showing ${name}.${cut ? " It is long, so only the start is shown." : ""}` };
7257
7375
  if (ext === "csv") {
7258
7376
  const rows3 = parseCsv(text3);
7259
7377
  const [head = [], ...body] = rows3;
7260
- return { ok: true, type: "table", props: { title: label3, columns: head, rows: body.slice(0, MAX_CSV_ROWS) }, summary: `Showing ${name} as a table (${Math.min(body.length, MAX_CSV_ROWS)} rows).` };
7378
+ return { ok: true, type: "table", props: { title: label4, columns: head, rows: body.slice(0, MAX_CSV_ROWS) }, summary: `Showing ${name} as a table (${Math.min(body.length, MAX_CSV_ROWS)} rows).` };
7261
7379
  }
7262
- return { ok: true, type: "code", props: { title: label3, language: CODE_LANG[ext], code: text3 + note.replace(/^\n\n/, "\n") }, summary: `Showing ${name}.${cut ? " It is long, so only the start is shown." : ""}` };
7380
+ return { ok: true, type: "code", props: { title: label4, language: CODE_LANG[ext], code: text3 + note.replace(/^\n\n/, "\n") }, summary: `Showing ${name}.${cut ? " It is long, so only the start is shown." : ""}` };
7263
7381
  }
7264
7382
  return { ok: false, error: `Friend cannot show .${ext || "?"} files. It can show images, GIFs, video, sound, PDF, Markdown, text, CSV and code.` };
7265
7383
  }
@@ -7314,7 +7432,7 @@ async function readLimited(res, max) {
7314
7432
  }
7315
7433
  return Buffer.concat(chunks);
7316
7434
  }
7317
- var clip2 = (s, n) => s.length > n ? `${s.slice(0, n)}\u2026` : s;
7435
+ var clip2 = (s, n2) => s.length > n2 ? `${s.slice(0, n2)}\u2026` : s;
7318
7436
  var plain = (s) => s.replace(/[\u0000-\u001f\u007f‪-‮⁦-⁩]/g, " ");
7319
7437
  var digits = (s) => s.replace(/\D/g, "");
7320
7438
  var ChannelHub = class _ChannelHub {
@@ -7813,8 +7931,8 @@ var AlexaDevices = class {
7813
7931
  const matches = [...this.waitingMap.values()].filter((w2) => short.length === SHORT && w2.id.endsWith(short));
7814
7932
  if (matches.length !== 1 || this.store.config().allowedUsers.length >= this.store.config().maxDevices) return false;
7815
7933
  const w = matches[0];
7816
- const label3 = name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) || "Echo";
7817
- this.store.update((a) => a.allowedUsers.push({ id: w.id, name: label3, addedAt: this.clock() }));
7934
+ const label4 = name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) || "Echo";
7935
+ this.store.update((a) => a.allowedUsers.push({ id: w.id, name: label4, addedAt: this.clock() }));
7818
7936
  this.waitingMap.delete(w.id);
7819
7937
  return true;
7820
7938
  }
@@ -8364,7 +8482,7 @@ var clock = (sec) => `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).pad
8364
8482
  var ref = (t) => ({ id: t.id, title: t.title, artist: t.artist, album: t.album, durationSec: t.durationSec });
8365
8483
  var refOfSource = (t) => ({ id: t.source === "local" ? parseRef(t.ref)?.id ?? t.ref : t.ref, title: t.title, artist: t.artist, album: t.album, durationSec: t.durationSec });
8366
8484
  var label2 = (t) => t.artist ? `${t.title} by ${t.artist}` : t.title;
8367
- var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
8485
+ var plural = (n2, w) => `${n2} ${w}${n2 === 1 ? "" : "s"}`;
8368
8486
  var SERVICE_NAMES = { spotify: "Spotify", soundcloud: "SoundCloud", audius: "Audius" };
8369
8487
  var MusicHub = class {
8370
8488
  constructor(d) {
@@ -8455,10 +8573,10 @@ var MusicHub = class {
8455
8573
  const mode = a.mode ?? "replace";
8456
8574
  const failed = await this.send({ action: "play", tracks: tracks.map(refOfSource), mode, ...a.shuffle ? { shuffle: true } : {} });
8457
8575
  if (failed) return failed;
8458
- const n = tracks.length;
8576
+ const n2 = tracks.length;
8459
8577
  const first2 = tracks[0];
8460
8578
  const at = this.at();
8461
- const base = mode === "add" ? `Added ${plural(n, "track")} to the queue${at}.` : mode === "next" ? `Queued ${plural(n, "track")} to play next${at}, starting with ${label2(first2)}.` : `Playing ${plural(n, "track")}${at}${a.shuffle ? " shuffled" : ", starting with " + label2(first2)}.`;
8579
+ const base = mode === "add" ? `Added ${plural(n2, "track")} to the queue${at}.` : mode === "next" ? `Queued ${plural(n2, "track")} to play next${at}, starting with ${label2(first2)}.` : `Playing ${plural(n2, "track")}${at}${a.shuffle ? " shuffled" : ", starting with " + label2(first2)}.`;
8462
8580
  return { ok: true, text: [base, ...notes].join(" ") };
8463
8581
  }
8464
8582
  async playLocal(a, words) {
@@ -8470,11 +8588,11 @@ var MusicHub = class {
8470
8588
  const failed = await this.send({ action: "play", tracks: found.tracks.map(ref), mode, ...a.shuffle ? { shuffle: true } : {} });
8471
8589
  if (failed) return failed;
8472
8590
  const first2 = found.tracks[0];
8473
- const n = found.tracks.length;
8591
+ const n2 = found.tracks.length;
8474
8592
  const at = this.at();
8475
- if (mode === "add") return { ok: true, text: `Added ${plural(n, "track")} to the queue${at}.` };
8476
- if (mode === "next") return { ok: true, text: `Queued ${plural(n, "track")} to play next${at}, starting with ${label2(first2)}.` };
8477
- return { ok: true, text: `Playing ${plural(n, "track")}${at}${a.shuffle ? " shuffled" : ", starting with " + label2(first2)}.` };
8593
+ if (mode === "add") return { ok: true, text: `Added ${plural(n2, "track")} to the queue${at}.` };
8594
+ if (mode === "next") return { ok: true, text: `Queued ${plural(n2, "track")} to play next${at}, starting with ${label2(first2)}.` };
8595
+ return { ok: true, text: `Playing ${plural(n2, "track")}${at}${a.shuffle ? " shuffled" : ", starting with " + label2(first2)}.` };
8478
8596
  }
8479
8597
  async control(a) {
8480
8598
  const bad = (text3) => ({ ok: false, text: text3 });
@@ -8838,8 +8956,8 @@ var SoundCloudHub = class {
8838
8956
  };
8839
8957
  }
8840
8958
  async search(query, limit) {
8841
- const n = Math.max(1, Math.min(50, Math.floor(limit)));
8842
- const j = await this.api(`/tracks?${new URLSearchParams({ q: query.slice(0, 200), limit: String(n), access: "playable" })}`);
8959
+ const n2 = Math.max(1, Math.min(50, Math.floor(limit)));
8960
+ const j = await this.api(`/tracks?${new URLSearchParams({ q: query.slice(0, 200), limit: String(n2), access: "playable" })}`);
8843
8961
  return (Array.isArray(j) ? j : []).flatMap((t) => this.of(t) ?? []);
8844
8962
  }
8845
8963
  async playlists(limit) {
@@ -8870,7 +8988,7 @@ var SoundCloudHub = class {
8870
8988
  }
8871
8989
  }
8872
8990
  /** Fetches the audio from a vetted address, following at most three redirects and only to SoundCloud's own https addresses. */
8873
- async fetchAudio(address, range, signal) {
8991
+ async fetchAudio(address, range2, signal) {
8874
8992
  let next = address;
8875
8993
  for (let hop = 0; hop <= MAX_HOPS3; hop++) {
8876
8994
  if (!isSoundCloudAddress(next)) return { ok: false, error: "SoundCloud sent the audio somewhere Friend will not follow." };
@@ -8880,7 +8998,7 @@ var SoundCloudHub = class {
8880
8998
  const timer = setTimeout(() => ctl.abort(), this.d.requestTimeoutMs ?? 8e3);
8881
8999
  const stop2 = signal ? AbortSignal.any([ctl.signal, signal]) : ctl.signal;
8882
9000
  try {
8883
- res = await (this.d.fetch ?? fetch)(next, { redirect: "manual", headers: range ? { range } : {}, signal: stop2 });
9001
+ res = await (this.d.fetch ?? fetch)(next, { redirect: "manual", headers: range2 ? { range: range2 } : {}, signal: stop2 });
8884
9002
  } finally {
8885
9003
  clearTimeout(timer);
8886
9004
  }
@@ -9739,8 +9857,8 @@ var SpotifyHub = class {
9739
9857
  };
9740
9858
  }
9741
9859
  async search(query, limit) {
9742
- const n = Math.max(1, Math.min(50, Math.floor(limit)));
9743
- const j = await this.api(`/v1/search?${new URLSearchParams({ q: query.slice(0, 200), type: "track", limit: String(n) })}`);
9860
+ const n2 = Math.max(1, Math.min(50, Math.floor(limit)));
9861
+ const j = await this.api(`/v1/search?${new URLSearchParams({ q: query.slice(0, 200), type: "track", limit: String(n2) })}`);
9744
9862
  return (j?.tracks?.items ?? []).flatMap((t) => this.of(t) ?? []);
9745
9863
  }
9746
9864
  async playlists(limit) {
@@ -9760,12 +9878,12 @@ var SpotifyHub = class {
9760
9878
  };
9761
9879
 
9762
9880
  // ../server/src/alexa/fast.ts
9763
- var rupees = (n, lang, o = {}) => {
9881
+ var rupees = (n2, lang, o = {}) => {
9764
9882
  const word2 = o.currencyName ?? "rupees";
9765
9883
  const spoken = lang === "hi" && word2 === "rupees" ? "\u0930\u0941\u092A\u092F\u0947" : word2;
9766
- return `${formatMoney(Math.abs(n), { symbol: "", locale: (o.money ?? DEFAULT_MONEY).locale }).replace(/[.,]00$/, "")} ${spoken}`;
9884
+ return `${formatMoney(Math.abs(n2), { symbol: "", locale: (o.money ?? DEFAULT_MONEY).locale }).replace(/[.,]00$/, "")} ${spoken}`;
9767
9885
  };
9768
- var pct = (n) => String(Math.round(Math.abs(n) * 100) / 100);
9886
+ var pct = (n2) => String(Math.round(Math.abs(n2) * 100) / 100);
9769
9887
  function portfolioSpeech(s, lang, o = {}) {
9770
9888
  if (s.value === 0 && s.invested === 0) return lang === "hi" ? "\u0906\u092A\u0915\u0947 \u0916\u093E\u0924\u0947 \u092E\u0947\u0902 \u0905\u092D\u0940 \u0915\u094B\u0908 \u0939\u094B\u0932\u094D\u0921\u093F\u0902\u0917 \u0928\u0939\u0940\u0902 \u0939\u0948\u0964" : "You have no holdings right now.";
9771
9889
  const up = s.dayChange >= 0;
@@ -9802,8 +9920,8 @@ var BrokerError = class extends Error {
9802
9920
  // ../server/src/brokers/portfolio.ts
9803
9921
  var MOVERS = 3;
9804
9922
  var TOP = 5;
9805
- var r2 = (n) => Number.isFinite(n) ? Math.round((n + Number.EPSILON) * 100) / 100 : 0;
9806
- function summarize(holdings) {
9923
+ var r2 = (n2) => Number.isFinite(n2) ? Math.round((n2 + Number.EPSILON) * 100) / 100 : 0;
9924
+ function summarize2(holdings) {
9807
9925
  let value = 0;
9808
9926
  let invested = 0;
9809
9927
  let yesterday = 0;
@@ -9874,7 +9992,7 @@ async function section(supported, read) {
9874
9992
  }
9875
9993
 
9876
9994
  // ../server/src/brokers/report.ts
9877
- var signedMoney = (n, m) => n > 4e-3 ? `+${formatMoney(n, m)}` : formatMoney(n, m);
9995
+ var signedMoney = (n2, m) => n2 > 4e-3 ? `+${formatMoney(n2, m)}` : formatMoney(n2, m);
9878
9996
  var signedPct = (p) => {
9879
9997
  const x = Number.isFinite(p) ? p : 0;
9880
9998
  const text3 = Math.abs(x).toFixed(2);
@@ -9883,7 +10001,7 @@ var signedPct = (p) => {
9883
10001
  var pct1 = (p) => Math.abs(p).toFixed(1);
9884
10002
  var TITLE = { morning: "Morning report", close: "Market close report", custom: "Portfolio report" };
9885
10003
  var WHY2 = { rate: "rate limit", session: "login expired", network: "network problem", auth: "login problem" };
9886
- var missing = (label3, s) => `${label3}: not available (${WHY2[s.kind] ?? s.error.slice(0, 60)})`;
10004
+ var missing = (label4, s) => `${label4}: not available (${WHY2[s.kind] ?? s.error.slice(0, 60)})`;
9887
10005
  var DEFAULT_REPORT_OPTIONS = { money: DEFAULT_MONEY, concentrationPct: 30, bigDropPct: 5 };
9888
10006
  function title(kind, broker, now, zone) {
9889
10007
  const parts = Object.fromEntries(
@@ -9895,15 +10013,15 @@ var dayMovePct = (h) => h.closePrice > 0 ? (h.lastPrice - h.closePrice) / h.clos
9895
10013
  function buildReport(input) {
9896
10014
  const { snapshot: snap, caps } = input;
9897
10015
  const o = input.options ?? DEFAULT_REPORT_OPTIONS;
9898
- const fmt = (n) => formatMoney(n, o.money);
9899
- const signed = (n) => signedMoney(n, o.money);
10016
+ const fmt = (n2) => formatMoney(n2, o.money);
10017
+ const signed = (n2) => signedMoney(n2, o.money);
9900
10018
  const out = [title(input.kind, input.broker, input.now, input.zone ?? "Asia/Kolkata"), ""];
9901
10019
  if (caps.holdings) {
9902
10020
  if (!snap.holdings.ok) out.push(missing("Holdings", snap.holdings));
9903
10021
  else if (snap.holdings.data.length === 0) out.push("No holdings.");
9904
10022
  else {
9905
10023
  const holdings = snap.holdings.data;
9906
- const s = summarize(holdings);
10024
+ const s = summarize2(holdings);
9907
10025
  out.push(`Portfolio: ${fmt(s.value)} (invested ${fmt(s.invested)})`);
9908
10026
  out.push(`Profit/loss: ${signed(s.pnl)} (${signedPct(s.pnlPct)})`);
9909
10027
  out.push(`Today: ${signed(s.dayChange)} (${signedPct(s.dayChangePct)})`);
@@ -10031,10 +10149,10 @@ var ReportSchedules = class {
10031
10149
  }
10032
10150
  /** Counts one try for this schedule on this local day and returns how many there have been today (including this one). */
10033
10151
  noteAttempt(id2, day) {
10034
- const row = this.db.prepare("select attempt_day d, attempts n from report_schedules where id = ?").get(id2);
10035
- const n = row && row.d === day ? row.n + 1 : 1;
10036
- this.db.prepare("update report_schedules set attempt_day = ?, attempts = ? where id = ?").run(day, n, id2);
10037
- return n;
10152
+ const row2 = this.db.prepare("select attempt_day d, attempts n from report_schedules where id = ?").get(id2);
10153
+ const n2 = row2 && row2.d === day ? row2.n + 1 : 1;
10154
+ this.db.prepare("update report_schedules set attempt_day = ?, attempts = ? where id = ?").run(day, n2, id2);
10155
+ return n2;
10038
10156
  }
10039
10157
  };
10040
10158
 
@@ -10116,8 +10234,8 @@ function nextSixIst(now) {
10116
10234
  return today > now ? today : today + DAY_MS;
10117
10235
  }
10118
10236
  var num2 = (v) => {
10119
- const n = Number(v ?? 0);
10120
- return Number.isFinite(n) ? n : 0;
10237
+ const n2 = Number(v ?? 0);
10238
+ return Number.isFinite(n2) ? n2 : 0;
10121
10239
  };
10122
10240
  var str = (v) => typeof v === "string" ? v : v === void 0 || v === null ? "" : String(v);
10123
10241
  var rows2 = (v) => Array.isArray(v) ? v : [];
@@ -10464,7 +10582,7 @@ var CodeBox = class {
10464
10582
  if (input.code.length > MAX_CODE_CHARS) return { ok: false, error: `The code is too long (the limit is ${MAX_CODE_CHARS} characters).` };
10465
10583
  const extra = Object.entries(input.files ?? {});
10466
10584
  if (extra.length > MAX_EXTRA_FILES) return { ok: false, error: "Too many files for the sandbox." };
10467
- if (extra.some(([n]) => !/^[A-Za-z0-9_.-]{1,64}$/.test(n) || n.startsWith("..") || Object.values(SETUP).some((s) => s.file === n))) {
10585
+ if (extra.some(([n2]) => !/^[A-Za-z0-9_.-]{1,64}$/.test(n2) || n2.startsWith("..") || Object.values(SETUP).some((s) => s.file === n2))) {
10468
10586
  return { ok: false, error: "That file name is not allowed in the sandbox." };
10469
10587
  }
10470
10588
  if (extra.reduce((sum, [, body]) => sum + body.length, 0) > MAX_EXTRA_FILE_CHARS) return { ok: false, error: "The sandbox files are too large." };
@@ -10799,6 +10917,22 @@ create table report_schedules (
10799
10917
  attempt_day text,
10800
10918
  attempts integer not null default 0
10801
10919
  );
10920
+ `
10921
+ },
10922
+ {
10923
+ id: 11,
10924
+ sql: `
10925
+ create table usage (
10926
+ id integer primary key autoincrement,
10927
+ at integer not null,
10928
+ day text not null,
10929
+ provider text not null,
10930
+ model text not null,
10931
+ source text not null,
10932
+ input integer not null,
10933
+ output integer not null
10934
+ );
10935
+ create index usage_day on usage (day);
10802
10936
  `
10803
10937
  }
10804
10938
  ];
@@ -10823,6 +10957,77 @@ function openDb(dataDir) {
10823
10957
  return db;
10824
10958
  }
10825
10959
 
10960
+ // ../server/src/usage.ts
10961
+ var USAGE_SOURCES = ["voice", "chat", "task", "channel"];
10962
+ var DAY_MS2 = 864e5;
10963
+ var USAGE_KEEP_DAYS = 400;
10964
+ var pad = (n2) => String(n2).padStart(2, "0");
10965
+ function localDay2(ms2) {
10966
+ const d = new Date(ms2);
10967
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
10968
+ }
10969
+ var count = (n2) => Number.isFinite(n2) && n2 > 0 ? Math.floor(n2) : 0;
10970
+ var label3 = (s, fallback) => {
10971
+ const t = String(s ?? "").trim().slice(0, 80);
10972
+ return /^[\w.:\/@+\- ]+$/.test(t) ? t : fallback;
10973
+ };
10974
+ function range(days) {
10975
+ if (!Number.isFinite(days)) return 30;
10976
+ return Math.min(366, Math.max(1, Math.floor(days)));
10977
+ }
10978
+ var UsageLog = class {
10979
+ constructor(db, now = Date.now) {
10980
+ this.db = db;
10981
+ this.now = now;
10982
+ }
10983
+ db;
10984
+ now;
10985
+ record(e) {
10986
+ const input = count(e.input);
10987
+ const output = count(e.output);
10988
+ if (input === 0 && output === 0) return;
10989
+ const at = this.now();
10990
+ const source = USAGE_SOURCES.includes(e.source) ? e.source : "other";
10991
+ this.db.prepare("insert into usage (at, day, provider, model, source, input, output) values (?, ?, ?, ?, ?, ?, ?)").run(at, localDay2(at), label3(e.provider, "unknown"), label3(e.model, "unknown"), source, input, output);
10992
+ }
10993
+ /** The first day (local) that a range of `n` days ending today covers. */
10994
+ since(n2) {
10995
+ return localDay2(this.now() - (n2 - 1) * DAY_MS2);
10996
+ }
10997
+ days(n2) {
10998
+ const r = range(n2);
10999
+ const rows3 = this.db.prepare("select day, sum(input) as input, sum(output) as output, count(*) as turns from usage where day >= ? group by day").all(this.since(r));
11000
+ const by = new Map(rows3.map((x) => [x.day, x]));
11001
+ const out = [];
11002
+ for (let i = r - 1; i >= 0; i--) {
11003
+ const day = localDay2(this.now() - i * DAY_MS2);
11004
+ out.push(by.get(day) ?? { day, input: 0, output: 0, turns: 0 });
11005
+ }
11006
+ return out;
11007
+ }
11008
+ totals(n2) {
11009
+ const r = this.db.prepare("select coalesce(sum(input), 0) as input, coalesce(sum(output), 0) as output, count(*) as turns from usage where day >= ?").get(this.since(range(n2)));
11010
+ return { input: r.input, output: r.output, turns: r.turns };
11011
+ }
11012
+ byModel(n2) {
11013
+ return this.db.prepare("select provider, model, sum(input) as input, sum(output) as output, count(*) as turns from usage where day >= ? group by provider, model order by sum(input) + sum(output) desc, model").all(this.since(range(n2)));
11014
+ }
11015
+ bySource(n2) {
11016
+ return this.db.prepare("select source, sum(input) as input, sum(output) as output, count(*) as turns from usage where day >= ? group by source order by sum(input) + sum(output) desc, source").all(this.since(range(n2)));
11017
+ }
11018
+ summary(n2) {
11019
+ const r = range(n2);
11020
+ return { range: r, days: this.days(r), total: this.totals(r), byModel: this.byModel(r), bySource: this.bySource(r) };
11021
+ }
11022
+ /** Removes rows older than `keepDays` days. Returns how many went. */
11023
+ prune(keepDays) {
11024
+ return this.db.prepare("delete from usage where day < ?").run(this.since(Math.max(1, Math.floor(keepDays)))).changes;
11025
+ }
11026
+ clear() {
11027
+ return this.db.prepare("delete from usage").run().changes;
11028
+ }
11029
+ };
11030
+
10826
11031
  // ../server/src/devices.ts
10827
11032
  import { randomUUID as randomUUID5 } from "node:crypto";
10828
11033
  var DeviceHub = class {
@@ -11196,10 +11401,10 @@ function createGateway(deps) {
11196
11401
  const size = stat2.size;
11197
11402
  reply.header("accept-ranges", "bytes").header("x-content-type-options", "nosniff").header("cache-control", "private, max-age=300");
11198
11403
  if (grant.mime === "image/svg+xml") reply.header("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
11199
- const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
11200
- if (range && (range[1] || range[2])) {
11201
- let start2 = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
11202
- let end = range[1] && range[2] ? Number(range[2]) : size - 1;
11404
+ const range2 = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
11405
+ if (range2 && (range2[1] || range2[2])) {
11406
+ let start2 = range2[1] ? Number(range2[1]) : Math.max(0, size - Number(range2[2]));
11407
+ let end = range2[1] && range2[2] ? Number(range2[2]) : size - 1;
11203
11408
  end = Math.min(end, size - 1);
11204
11409
  if (!(start2 <= end) || start2 >= size) return reply.code(416).header("content-range", `bytes */${size}`).send();
11205
11410
  start2 = Math.max(0, start2);
@@ -11366,10 +11571,10 @@ function createGateway(deps) {
11366
11571
  if (!info || !info.isFile()) return reply.code(404).send({ error: "The file is gone" });
11367
11572
  const size = info.size;
11368
11573
  reply.header("accept-ranges", "bytes").header("x-content-type-options", "nosniff").header("cache-control", "private, max-age=300");
11369
- const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
11370
- if (range && (range[1] || range[2])) {
11371
- let start2 = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
11372
- let end = range[1] && range[2] ? Number(range[2]) : size - 1;
11574
+ const range2 = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
11575
+ if (range2 && (range2[1] || range2[2])) {
11576
+ let start2 = range2[1] ? Number(range2[1]) : Math.max(0, size - Number(range2[2]));
11577
+ let end = range2[1] && range2[2] ? Number(range2[2]) : size - 1;
11373
11578
  end = Math.min(end, size - 1);
11374
11579
  if (!(start2 <= end) || start2 >= size) return reply.code(416).header("content-range", `bytes */${size}`).send();
11375
11580
  start2 = Math.max(0, start2);
@@ -11437,10 +11642,10 @@ function createGateway(deps) {
11437
11642
  app.get("/api/music/stream/:ref", { onRequest: musicGuard }, async (req, reply) => {
11438
11643
  const ctl = new AbortController();
11439
11644
  req.raw.on("close", () => ctl.abort());
11440
- const range = typeof req.headers.range === "string" && /^bytes=\d*-\d*$/.test(req.headers.range) ? req.headers.range : void 0;
11645
+ const range2 = typeof req.headers.range === "string" && /^bytes=\d*-\d*$/.test(req.headers.range) ? req.headers.range : void 0;
11441
11646
  let r;
11442
11647
  try {
11443
- r = await deps.music.stream(req.params.ref, range, ctl.signal);
11648
+ r = await deps.music.stream(req.params.ref, range2, ctl.signal);
11444
11649
  } catch {
11445
11650
  return reply.code(502).send({ error: "That track could not be played right now." });
11446
11651
  }
@@ -11624,6 +11829,34 @@ function createGateway(deps) {
11624
11829
  return systemError(reply, e, "autostart");
11625
11830
  }
11626
11831
  });
11832
+ const usageGuard = async (req, reply) => {
11833
+ const token = req.cookies[COOKIE_NAME];
11834
+ if (!(token && auth.resolveSession(token))) return reply.code(401).send({ error: "Unauthorized" });
11835
+ if (!deps.usage) return reply.code(503).send({ error: "Not available" });
11836
+ };
11837
+ app.get("/api/usage", { onRequest: usageGuard }, async (req) => deps.usage.summary(Number(req.query.days ?? 30)));
11838
+ app.delete("/api/usage", { onRequest: usageGuard }, async () => {
11839
+ const removed = deps.usage.clear();
11840
+ audit.log("owner", "usage.cleared", { removed });
11841
+ return { removed };
11842
+ });
11843
+ const sessionGuard = async (req, reply) => {
11844
+ const token = req.cookies[COOKIE_NAME];
11845
+ if (!(token && auth.resolveSession(token))) return reply.code(401).send({ error: "Unauthorized" });
11846
+ if (!deps.session) return reply.code(503).send({ error: "Not available" });
11847
+ };
11848
+ app.get("/api/conversation", { onRequest: sessionGuard }, async () => deps.session.info());
11849
+ app.post("/api/conversation/compact", { onRequest: sessionGuard }, async () => deps.session.compact());
11850
+ app.post("/api/conversation/clear", { onRequest: sessionGuard }, async (req, reply) => {
11851
+ const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
11852
+ if (!body.success) return reply.code(400).send({ error: "Enter your PIN" });
11853
+ try {
11854
+ return await deps.session.clear(body.data.pin, req.ip);
11855
+ } catch (e) {
11856
+ if (e instanceof HttpError) return reply.code(e.status).send({ error: e.message });
11857
+ throw e;
11858
+ }
11859
+ });
11627
11860
  const chromeFailures = new SlidingWindow(20, 6e4);
11628
11861
  const chromeWss = new WebSocketServer({ noServer: true, maxPayload: MAX_DEVICE_FRAME_BYTES });
11629
11862
  if (deps.chrome) {
@@ -12083,8 +12316,8 @@ Connection: close\r
12083
12316
 
12084
12317
  // ../server/src/provider/claude.ts
12085
12318
  import Anthropic from "@anthropic-ai/sdk";
12086
- var encodeName = (n) => n.replaceAll(".", "__");
12087
- var decodeName = (n) => n.replaceAll("__", ".");
12319
+ var encodeName = (n2) => n2.replaceAll(".", "__");
12320
+ var decodeName = (n2) => n2.replaceAll("__", ".");
12088
12321
  var toAnthropicImage = (b) => ({
12089
12322
  type: "image",
12090
12323
  source: { type: "base64", media_type: b.mime, data: b.data }
@@ -12336,7 +12569,7 @@ function readBody(req) {
12336
12569
  var LIMIT = /\b(?:hit|reached)\s+(?:your\s+|the\s+)?(?:[\w-]+\s+)?(?:usage\s+)?limit\b|\busage\s+limit\b|\b(?:\d+-hour|weekly|daily|monthly)\s+limit\s+reached\b|\bhit your (?:weekly|daily|monthly|usage|\d+-hour) limit\b/i;
12337
12570
  var DEFAULT_BACKOFF_MS = 15 * 6e4;
12338
12571
  var MIN_BACKOFF_MS = 3e4;
12339
- var DAY_MS2 = 864e5;
12572
+ var DAY_MS3 = 864e5;
12340
12573
  function isUsageLimit(text3) {
12341
12574
  return LIMIT.test(text3);
12342
12575
  }
@@ -12361,7 +12594,7 @@ function parseResetDelayMs(message, now = /* @__PURE__ */ new Date()) {
12361
12594
  const zoneNow = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
12362
12595
  const offset = zoneNow - now.getTime();
12363
12596
  let reset2 = Date.UTC(get("year"), get("month") - 1, get("day"), hour, Number(min), 0) - offset;
12364
- if (reset2 <= now.getTime()) reset2 += DAY_MS2;
12597
+ if (reset2 <= now.getTime()) reset2 += DAY_MS3;
12365
12598
  const delay = reset2 - now.getTime();
12366
12599
  return Number.isFinite(delay) && delay > 0 ? delay : void 0;
12367
12600
  } catch {
@@ -12496,6 +12729,8 @@ var ClaudeCodeProvider = class {
12496
12729
  id = "claude-code";
12497
12730
  vision = true;
12498
12731
  owner = newConv(false);
12732
+ /** Set when the owner's conversation was reset and the new one has not begun yet: what it starts from (a summary, or nothing). */
12733
+ fresh = null;
12499
12734
  guests = /* @__PURE__ */ new Set();
12500
12735
  bridge;
12501
12736
  bridgeReady = null;
@@ -12538,6 +12773,25 @@ var ClaudeCodeProvider = class {
12538
12773
  if (guest) this.endGuest(conv);
12539
12774
  }
12540
12775
  }
12776
+ /** Forgets the owner's conversation. The next message starts a new Claude with `seed` (if any) instead of the old story. */
12777
+ async resetSession(seed) {
12778
+ const conv = this.owner;
12779
+ const proc = conv.proc;
12780
+ conv.proc = null;
12781
+ proc?.kill();
12782
+ await proc?.exited;
12783
+ conv.inbox.clear();
12784
+ conv.awaiting = [];
12785
+ conv.hasContext = false;
12786
+ conv.systemKey = "";
12787
+ this.forgetSession(conv);
12788
+ mkdirSync9(this.home, { recursive: true });
12789
+ this.fresh = { seed: seed?.trim() || null };
12790
+ try {
12791
+ writeFileSync8(this.freshFile(), JSON.stringify(this.fresh));
12792
+ } catch {
12793
+ }
12794
+ }
12541
12795
  async close() {
12542
12796
  this.bridge.failPending("Friend is stopping.");
12543
12797
  this.owner.proc?.kill();
@@ -12600,7 +12854,9 @@ var ClaudeCodeProvider = class {
12600
12854
  const text3 = String(ev.result ?? turnText);
12601
12855
  if (!ev.is_error) {
12602
12856
  const u = ev.usage ?? {};
12603
- yield { type: "usage", input: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), output: u.output_tokens ?? 0 };
12857
+ const sum = (x) => (x.input_tokens ?? 0) + (x.cache_read_input_tokens ?? 0) + (x.cache_creation_input_tokens ?? 0);
12858
+ const last = Array.isArray(u.iterations) ? u.iterations.at(-1) : void 0;
12859
+ yield { type: "usage", input: sum(u), output: u.output_tokens ?? 0, ...last ? { context: sum(last) } : {} };
12604
12860
  yield { type: "done" };
12605
12861
  return "ok";
12606
12862
  }
@@ -12694,6 +12950,7 @@ ${item.stderr}`;
12694
12950
  mkdirSync9(this.home, { recursive: true });
12695
12951
  if (!conv.guest) {
12696
12952
  conv.sessionId ??= this.readSession();
12953
+ this.fresh = conv.sessionId ? null : this.readFresh();
12697
12954
  await this.startBridge();
12698
12955
  this.bridge.setTools(req.tools);
12699
12956
  }
@@ -12770,11 +13027,26 @@ ${item.stderr}`;
12770
13027
  rememberSession(conv, id2) {
12771
13028
  if (conv.guest || conv.sessionId === id2) return;
12772
13029
  conv.sessionId = id2;
13030
+ this.fresh = null;
13031
+ try {
13032
+ rmSync8(this.freshFile(), { force: true });
13033
+ } catch {
13034
+ }
12773
13035
  try {
12774
13036
  writeFileSync8(this.sessionFile(), id2);
12775
13037
  } catch {
12776
13038
  }
12777
13039
  }
13040
+ freshFile = () => join16(this.home, "fresh.json");
13041
+ readFresh() {
13042
+ try {
13043
+ if (!existsSync14(this.freshFile())) return null;
13044
+ const r = JSON.parse(readFileSync8(this.freshFile(), "utf8"));
13045
+ return { seed: typeof r.seed === "string" && r.seed ? r.seed : null };
13046
+ } catch {
13047
+ return null;
13048
+ }
13049
+ }
12778
13050
  forgetSession(conv) {
12779
13051
  conv.sessionId = null;
12780
13052
  try {
@@ -12794,7 +13066,11 @@ ${item.stderr}`;
12794
13066
  }
12795
13067
  }
12796
13068
  const blocks = [];
12797
- if (!conv.hasContext && last >= 0) blocks.push({ type: "text", text: transcript(msgs.slice(0, last + 1)) });
13069
+ const fresh = conv.hasContext ? null : this.fresh;
13070
+ if (fresh) {
13071
+ if (fresh.seed) blocks.push({ type: "text", text: `Summary of our earlier conversation (for context only, it is already over):
13072
+ ${fresh.seed}` });
13073
+ } else if (!conv.hasContext && last >= 0) blocks.push({ type: "text", text: transcript(msgs.slice(0, last + 1)) });
12798
13074
  const ran = /* @__PURE__ */ new Map();
12799
13075
  for (const m of msgs.slice(last + 1)) {
12800
13076
  for (const b of m.content) {
@@ -12857,6 +13133,9 @@ var FallbackProvider = class {
12857
13133
  yield ev;
12858
13134
  }
12859
13135
  }
13136
+ async resetSession(seed) {
13137
+ await Promise.all([this.primary.resetSession?.(seed), this.other.resetSession?.(seed)]);
13138
+ }
12860
13139
  async close() {
12861
13140
  await Promise.all([this.primary.close?.(), this.other.close?.()]);
12862
13141
  }
@@ -12896,6 +13175,9 @@ var SwitchableProvider = class {
12896
13175
  this.current = next;
12897
13176
  return old;
12898
13177
  }
13178
+ async resetSession(seed) {
13179
+ await this.current.resetSession?.(seed);
13180
+ }
12899
13181
  async close() {
12900
13182
  await this.current.close?.();
12901
13183
  }
@@ -13042,7 +13324,8 @@ var SettingsPatch = z11.object({
13042
13324
  duck: z11.boolean(),
13043
13325
  duckPercent: z11.number().int().min(0).max(100)
13044
13326
  }).partial().strict(),
13045
- updates: z11.object({ auto: z11.boolean() }).partial().strict()
13327
+ updates: z11.object({ auto: z11.boolean() }).partial().strict(),
13328
+ session: z11.object({ autoCompact: z11.boolean(), compactAtTokens: z11.number().int().min(5e4).max(8e5) }).partial().strict()
13046
13329
  }).partial().strict();
13047
13330
  function settingsView(c) {
13048
13331
  return {
@@ -13106,7 +13389,8 @@ function settingsView(c) {
13106
13389
  duck: c.voice.duck,
13107
13390
  duckPercent: c.voice.duckPercent
13108
13391
  },
13109
- updates: { auto: c.updates.auto }
13392
+ updates: { auto: c.updates.auto },
13393
+ session: { autoCompact: c.session.autoCompact, compactAtTokens: c.session.compactAtTokens }
13110
13394
  };
13111
13395
  }
13112
13396
  var SettingsError = class extends Error {
@@ -13264,6 +13548,7 @@ function applySettings(config, raw) {
13264
13548
  }
13265
13549
  if (p.claudeCode) Object.assign(config.claudeCode, p.claudeCode);
13266
13550
  if (p.updates) Object.assign(config.updates, p.updates);
13551
+ if (p.session) Object.assign(config.session, p.session);
13267
13552
  if (p.voice) {
13268
13553
  const safe = next.voice;
13269
13554
  for (const k of Object.keys(p.voice)) config.voice[k] = safe[k];
@@ -13281,8 +13566,8 @@ function clientLook(l) {
13281
13566
 
13282
13567
  // ../server/src/provider/ollama.ts
13283
13568
  import { Agent as Agent2, fetch as undiciFetch } from "undici";
13284
- var encodeName2 = (n) => n.replaceAll(".", "__");
13285
- var decodeName2 = (n) => n.replaceAll("__", ".");
13569
+ var encodeName2 = (n2) => n2.replaceAll(".", "__");
13570
+ var decodeName2 = (n2) => n2.replaceAll("__", ".");
13286
13571
  function toOllamaMessages(system, messages, vision) {
13287
13572
  const out = [{ role: "system", content: system }];
13288
13573
  const nameById = /* @__PURE__ */ new Map();
@@ -13452,8 +13737,8 @@ var OllamaProvider = class {
13452
13737
 
13453
13738
  // ../server/src/provider/openai.ts
13454
13739
  import { Agent as Agent3, fetch as undiciFetch2 } from "undici";
13455
- var encodeName3 = (n) => n.replaceAll(".", "__");
13456
- var decodeName3 = (n) => n.replaceAll("__", ".");
13740
+ var encodeName3 = (n2) => n2.replaceAll(".", "__");
13741
+ var decodeName3 = (n2) => n2.replaceAll("__", ".");
13457
13742
  function toOpenAiMessages(system, messages, vision) {
13458
13743
  const out = [{ role: "system", content: system }];
13459
13744
  for (const m of messages) {
@@ -13945,7 +14230,7 @@ function currentSelf() {
13945
14230
  // ../server/src/system/npm.ts
13946
14231
  import { spawn as spawn7 } from "node:child_process";
13947
14232
  var PACKAGE = "friend-assistant";
13948
- var runningVersion = () => true ? "0.1.45" : "development";
14233
+ var runningVersion = () => true ? "0.1.47" : "development";
13949
14234
  async function npmLatest(fetchFn = fetch, timeoutMs = 1e4) {
13950
14235
  try {
13951
14236
  const res = await fetchFn(`https://registry.npmjs.org/${PACKAGE}/latest`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
@@ -13973,8 +14258,8 @@ function spawnUpgrade(self, platform = process.platform) {
13973
14258
  // ../server/src/system/updater.ts
13974
14259
  function isNewerVersion(a, b) {
13975
14260
  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)];
14261
+ const n2 = (v) => v.split(/[.-]/).slice(0, 3).map((x2) => Number(x2) || 0);
14262
+ const [x, y] = [n2(a), n2(b)];
13978
14263
  for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
13979
14264
  return false;
13980
14265
  }
@@ -14260,9 +14545,9 @@ var Voiceprint = class {
14260
14545
  }
14261
14546
  /** Similarity of a new voiceprint to the owner's, or null when nobody is enrolled. Throws on a wrong key. */
14262
14547
  score(embedding) {
14263
- const row = this.db.prepare("select centroid_enc e, dim from voiceprint where id = 1").get();
14264
- if (!row) return null;
14265
- const raw = decrypt(this.key, row.e);
14548
+ const row2 = this.db.prepare("select centroid_enc e, dim from voiceprint where id = 1").get();
14549
+ if (!row2) return null;
14550
+ const raw = decrypt(this.key, row2.e);
14266
14551
  const stored = Array.from(new Float32Array(raw.buffer, raw.byteOffset, raw.byteLength / 4));
14267
14552
  return cosine(stored, embedding);
14268
14553
  }
@@ -14303,10 +14588,10 @@ var Enrollment = class {
14303
14588
  if (!checkConsistency(this.samples, CONSISTENT).ok) {
14304
14589
  throw new EnrollError("The recordings do not agree with each other. They must all be the same person; start over.");
14305
14590
  }
14306
- const n = this.samples.length;
14591
+ const n2 = this.samples.length;
14307
14592
  this.voiceprint.enroll(this.samples, this.model);
14308
14593
  this.samples = [];
14309
- return { samples: n };
14594
+ return { samples: n2 };
14310
14595
  }
14311
14596
  cancel() {
14312
14597
  this.samples = [];
@@ -14322,17 +14607,17 @@ var GREETINGS = /* @__PURE__ */ new Set(["hey", "hay", "hi", "hello", "hellow",
14322
14607
  var WORD = /[\p{L}\p{M}\p{N}'’]+/gu;
14323
14608
  var GAP = /^[\s,.:;!?…\-–—]+/u;
14324
14609
  function distance(a, b) {
14325
- const row = Array.from({ length: b.length + 1 }, (_, i) => i);
14610
+ const row2 = Array.from({ length: b.length + 1 }, (_, i) => i);
14326
14611
  for (let i = 1; i <= a.length; i++) {
14327
- let prev = row[0];
14328
- row[0] = i;
14612
+ let prev = row2[0];
14613
+ row2[0] = i;
14329
14614
  for (let j = 1; j <= b.length; j++) {
14330
- const keep = row[j];
14331
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
14615
+ const keep = row2[j];
14616
+ row2[j] = Math.min(row2[j] + 1, row2[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
14332
14617
  prev = keep;
14333
14618
  }
14334
14619
  }
14335
- return row[b.length];
14620
+ return row2[b.length];
14336
14621
  }
14337
14622
  var sameWord = (heard, name) => heard === name || name.length >= 4 && distance(heard, name) <= 1;
14338
14623
  function matchWake(text3, name) {
@@ -14703,6 +14988,8 @@ async function createApp(opts) {
14703
14988
  const config = { ...loadConfig(opts.dataDir), ...opts.config };
14704
14989
  const db = openDb(opts.dataDir);
14705
14990
  const audit = new Audit(db);
14991
+ const usageLog = new UsageLog(db);
14992
+ usageLog.prune(USAGE_KEEP_DAYS);
14706
14993
  const auth = new Auth(db, audit);
14707
14994
  const policy = new PolicyEngine(db, config);
14708
14995
  const devices = new DeviceHub();
@@ -14722,6 +15009,13 @@ async function createApp(opts) {
14722
15009
  if (config.provider === "openai") return new OpenAiProvider({ ...config.openai });
14723
15010
  return config.apiKey ? new ClaudeProvider({ apiKey: config.apiKey, model: config.model }) : new MissingKeyProvider();
14724
15011
  };
15012
+ const modelInUse = () => {
15013
+ if (opts.provider) return { provider: "custom", model: "custom" };
15014
+ if (config.provider === "claude-code") return { provider: "claude-code", model: config.claudeCode.model };
15015
+ if (config.provider === "ollama") return { provider: "ollama", model: config.ollama.model };
15016
+ if (config.provider === "openai") return { provider: "openai", model: config.openai.model };
15017
+ return { provider: "claude", model: config.model };
15018
+ };
14725
15019
  const switchable = opts.provider ? null : new SwitchableProvider(makeProvider());
14726
15020
  const provider = opts.provider ?? switchable;
14727
15021
  const camera = new CameraBroker(opts.cameraTimeoutMs);
@@ -14753,7 +15047,7 @@ async function createApp(opts) {
14753
15047
  if (m.type === "caption.delta") reply += m.text;
14754
15048
  else if (m.type === "caption.end") reply += "\n";
14755
15049
  };
14756
- await agent.runTurn(text3, { trust: "owner", emit, ip: `channel:${via}` });
15050
+ await agent.runTurn(text3, { trust: "owner", emit, ip: `channel:${via}`, source: "channel" });
14757
15051
  return reply.trim();
14758
15052
  }
14759
15053
  });
@@ -15059,6 +15353,8 @@ async function createApp(opts) {
15059
15353
  mcp: hub,
15060
15354
  admin,
15061
15355
  provider,
15356
+ usage: (e) => usageLog.record({ ...e, ...modelInUse() }),
15357
+ session: { auto: () => ({ on: config.session.autoCompact, limit: config.session.compactAtTokens }) },
15062
15358
  policy,
15063
15359
  approvals,
15064
15360
  devices,
@@ -15101,7 +15397,7 @@ async function createApp(opts) {
15101
15397
  store: { config: () => config.alexa, update: (fn) => void changeAlexaConfig(fn) },
15102
15398
  verify: { fetchCert: opts.alexaFetchCert ?? fetchAmazonCert, roots: opts.alexaRoots },
15103
15399
  clock: Date.now,
15104
- agent: { isBusy: () => agent.isBusy(), runTurn: (text3, ctx) => agent.runTurn(text3, ctx) },
15400
+ agent: { isBusy: () => agent.isBusy(), runTurn: (text3, ctx) => agent.runTurn(text3, { source: "task", ...ctx }) },
15105
15401
  broadcast,
15106
15402
  screenOpen: () => clients.size > 0,
15107
15403
  fast: {
@@ -15109,7 +15405,7 @@ async function createApp(opts) {
15109
15405
  const got = await brokerHub.snapshot("zerodha");
15110
15406
  if (!got.ok) return got.sessionExpired ? "Zerodha needs you to log in first." : got.error;
15111
15407
  if (!got.snapshot.holdings.ok) return "I could not read your holdings just now.";
15112
- return portfolioSpeech(summarize(got.snapshot.holdings.data), locale2.startsWith("hi") ? "hi" : "en", { money: { symbol: config.brokers.currencySymbol, locale: config.brokers.numberLocale }, currencyName: config.brokers.currencyName });
15408
+ return portfolioSpeech(summarize2(got.snapshot.holdings.data), locale2.startsWith("hi") ? "hi" : "en", { money: { symbol: config.brokers.currencySymbol, locale: config.brokers.numberLocale }, currencyName: config.brokers.currencyName });
15113
15409
  },
15114
15410
  WeatherIntent: async (_locale, slots) => {
15115
15411
  if (!slots.city) return "Tell me which city, for example: what is the weather in Pune.";
@@ -15222,7 +15518,7 @@ async function createApp(opts) {
15222
15518
  if (m.type === "caption.delta" && epoch === lockEpoch) talk?.push(m.text);
15223
15519
  broadcast(m);
15224
15520
  };
15225
- await agent.runTurn(text3, { trust, emit, ip, ...person ? { person } : {}, ...spoken ? { spoken } : {} });
15521
+ await agent.runTurn(text3, { trust, emit, ip, source: "voice", ...person ? { person } : {}, ...spoken ? { spoken } : {} });
15226
15522
  await talk?.end();
15227
15523
  }
15228
15524
  async function confirmChallenge(id2, pin, conn) {
@@ -15464,6 +15760,21 @@ async function createApp(opts) {
15464
15760
  connectors: connectorsApi,
15465
15761
  chrome: chromeRemoteApi,
15466
15762
  system: systemApi,
15763
+ usage: usageLog,
15764
+ session: {
15765
+ info: () => ({ supported: !!provider.resetSession, contextTokens: agent.contextTokens(), autoCompact: config.session.autoCompact, compactAtTokens: config.session.compactAtTokens }),
15766
+ compact: async () => {
15767
+ const r = await agent.compact();
15768
+ if (r.ok) audit.log("owner", "session.compacted", {});
15769
+ return r;
15770
+ },
15771
+ clear: async (pin, ip) => {
15772
+ await requirePin(pin, ip);
15773
+ const r = await agent.clear();
15774
+ if (r.ok) audit.log("owner", "session.cleared", { ip });
15775
+ return r;
15776
+ }
15777
+ },
15467
15778
  video: {
15468
15779
  stream: (id2) => videoHub.streamOf(id2),
15469
15780
  status: async () => videoHub.status() ?? await videoHub.refresh(),
@@ -15779,7 +16090,7 @@ async function createApp(opts) {
15779
16090
  audit.log("owner", "music.soundcloud.disconnect", { ip });
15780
16091
  return { ok: true };
15781
16092
  },
15782
- stream: async (ref2, range, signal) => {
16093
+ stream: async (ref2, range2, signal) => {
15783
16094
  const p = parseRef(ref2);
15784
16095
  const svc = p?.source === "soundcloud" ? soundcloudHub : p?.source === "audius" ? audiusHub : null;
15785
16096
  if (!p || !svc) return { ok: false, status: 404, error: "Not found" };
@@ -15787,7 +16098,7 @@ async function createApp(opts) {
15787
16098
  if (!st.connected) return { ok: false, status: 404, error: st.needs ?? "That music source is not connected." };
15788
16099
  const s = await svc.streamOf(p.id);
15789
16100
  if (!s.ok) return { ok: false, status: 404, error: s.error };
15790
- const a = await svc.fetchAudio(s.url, range, signal);
16101
+ const a = await svc.fetchAudio(s.url, range2, signal);
15791
16102
  if (!a.ok) return { ok: false, status: 502, error: a.error };
15792
16103
  const h = a.res.headers;
15793
16104
  const type = h.get("content-type") ?? "";
@@ -15887,7 +16198,7 @@ ${url}`);
15887
16198
  if (speaker && voiceAvailable() && config.voice.speakTyped) {
15888
16199
  void runVoiceTurn(msg.text, conn.ip, lockEpoch).catch((e) => audit.log("system", "turn.crash", { message: e.message }));
15889
16200
  } else {
15890
- void agent.runTurn(msg.text, { trust: "owner", emit: broadcast, ip: conn.ip }).catch((e) => audit.log("system", "turn.crash", { message: e.message }));
16201
+ void agent.runTurn(msg.text, { trust: "owner", emit: broadcast, ip: conn.ip, source: "chat" }).catch((e) => audit.log("system", "turn.crash", { message: e.message }));
15891
16202
  }
15892
16203
  break;
15893
16204
  case "approval.respond":
@@ -16195,14 +16506,14 @@ var nonEmpty = (v) => {
16195
16506
  return v.trim();
16196
16507
  };
16197
16508
  var unit2 = (v) => {
16198
- const n = Number(v);
16199
- if (v.trim() === "" || !Number.isFinite(n) || n < 0 || n > 1) throw new Error("Expected a number between 0 and 1");
16200
- return n;
16509
+ const n2 = Number(v);
16510
+ if (v.trim() === "" || !Number.isFinite(n2) || n2 < 0 || n2 > 1) throw new Error("Expected a number between 0 and 1");
16511
+ return n2;
16201
16512
  };
16202
16513
  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);
16514
+ const n2 = Number(v);
16515
+ if (v.trim() === "" || !Number.isFinite(n2) || n2 < 0 || n2 > 100) throw new Error("Expected a number between 0 and 100");
16516
+ return Math.round(n2);
16206
16517
  };
16207
16518
  var httpUrl = (v) => {
16208
16519
  let u;
@@ -16234,9 +16545,9 @@ var PARSERS = {
16234
16545
  },
16235
16546
  https: bool,
16236
16547
  port: (v) => {
16237
- const n = Number(v);
16238
- if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("Port must be a number between 0 and 65535");
16239
- return n;
16548
+ const n2 = Number(v);
16549
+ if (!Number.isInteger(n2) || n2 < 0 || n2 > 65535) throw new Error("Port must be a number between 0 and 65535");
16550
+ return n2;
16240
16551
  },
16241
16552
  provider: (v) => {
16242
16553
  if (v !== "claude" && v !== "ollama" && v !== "claude-code" && v !== "openai") throw new Error("Provider must be claude, ollama, claude-code or openai");
@@ -16304,9 +16615,9 @@ var GROUPS = {
16304
16615
  model: nonEmpty,
16305
16616
  vision: bool,
16306
16617
  contextTokens: (v) => {
16307
- const n = Number(v);
16308
- if (!Number.isInteger(n) || n < 1024 || n > 131072) throw new Error("contextTokens must be a whole number between 1024 and 131072");
16309
- return n;
16618
+ const n2 = Number(v);
16619
+ if (!Number.isInteger(n2) || n2 < 1024 || n2 > 131072) throw new Error("contextTokens must be a whole number between 1024 and 131072");
16620
+ return n2;
16310
16621
  }
16311
16622
  }
16312
16623
  };
@@ -16759,15 +17070,15 @@ function urlsFor(bind, port, ifaces = networkInterfaces2, https = false) {
16759
17070
  }
16760
17071
  var PORT_HELP = "The port must be a whole number from 1024 to 65535.";
16761
17072
  function parsePort(v) {
16762
- const n = Number(v);
16763
- if (v === void 0 || v.trim() === "" || !Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(PORT_HELP);
16764
- return n;
17073
+ const n2 = Number(v);
17074
+ if (v === void 0 || v.trim() === "" || !Number.isInteger(n2) || n2 < 1024 || n2 > 65535) throw new Error(PORT_HELP);
17075
+ return n2;
16765
17076
  }
16766
17077
  function show(io2, bind, port, ifaces, https = false) {
16767
17078
  const u = urlsFor(bind, port, ifaces, https);
16768
17079
  io2.out(`Listens on: ${bind}:${port}`);
16769
17080
  io2.out(`On this PC: ${u.local}`);
16770
- if (u.network.length) for (const n of u.network) io2.out(`On your network: ${n}`);
17081
+ if (u.network.length) for (const n2 of u.network) io2.out(`On your network: ${n2}`);
16771
17082
  else if (isLoopbackBind(bind)) io2.out("On your network: not reachable (only this PC). Turn it on with: friend lan on");
16772
17083
  else io2.out("On your network: no network address found. Check that this PC is connected.");
16773
17084
  }
@@ -16881,7 +17192,7 @@ var defaultRunner = (cmd, args, opts) => new Promise((res) => {
16881
17192
  child.on("error", (e) => res({ code: 127, stdout: stdout + e.message }));
16882
17193
  child.on("close", (code) => res({ code: code ?? 1, stdout }));
16883
17194
  });
16884
- var tail = (s, n = 8) => s.trim().split(/\r?\n/).slice(-n).join("\n");
17195
+ var tail = (s, n2 = 8) => s.trim().split(/\r?\n/).slice(-n2).join("\n");
16885
17196
  async function findPython(run2) {
16886
17197
  const candidates = [
16887
17198
  ...process.env.FRIEND_PYTHON ? [[process.env.FRIEND_PYTHON, []]] : [],
@@ -17128,7 +17439,7 @@ async function startFriend(opts) {
17128
17439
  });
17129
17440
  const urls = urlsFor(app.config.bind, port, void 0, app.config.https);
17130
17441
  opts.out(`Friend is running at ${urls.local}`);
17131
- for (const n of urls.network) opts.out(`Also on your network: ${n}`);
17442
+ for (const n2 of urls.network) opts.out(`Also on your network: ${n2}`);
17132
17443
  if (!app.auth.hasOwner()) opts.out("No owner yet. Run: friend onboard (guided), or friend setup (just the account)");
17133
17444
  const voiceAvailable = () => voice2?.available() ?? false;
17134
17445
  let lastAvailable = voiceAvailable();
@@ -17307,7 +17618,7 @@ async function memoryCommand(args, io2) {
17307
17618
  if (sub === "list") {
17308
17619
  const notes = memory.list();
17309
17620
  if (notes.length === 0) io2.out("No notes saved.");
17310
- for (const n of notes) io2.out(`#${n.id} ${n.text} (${n.source}, ${new Date(n.createdAt).toLocaleDateString()})`);
17621
+ for (const n2 of notes) io2.out(`#${n2.id} ${n2.text} (${n2.source}, ${new Date(n2.createdAt).toLocaleDateString()})`);
17311
17622
  return 0;
17312
17623
  }
17313
17624
  if (sub === "forget") {
@@ -17324,8 +17635,8 @@ async function memoryCommand(args, io2) {
17324
17635
  io2.out(`Forgot note #${id2}.`);
17325
17636
  return 0;
17326
17637
  }
17327
- const count2 = memory.count();
17328
- if (count2 === 0) {
17638
+ const count3 = memory.count();
17639
+ if (count3 === 0) {
17329
17640
  io2.out("No notes saved.");
17330
17641
  return 0;
17331
17642
  }
@@ -17342,8 +17653,8 @@ async function memoryCommand(args, io2) {
17342
17653
  return 1;
17343
17654
  }
17344
17655
  memory.clear();
17345
- audit.log("owner", "memory.clear", { count: count2, via: "cli" });
17346
- io2.out(`Deleted ${count2} notes.`);
17656
+ audit.log("owner", "memory.clear", { count: count3, via: "cli" });
17657
+ io2.out(`Deleted ${count3} notes.`);
17347
17658
  return 0;
17348
17659
  } finally {
17349
17660
  db.close();
@@ -17359,7 +17670,7 @@ function defaultCatalog() {
17359
17670
  const here2 = dirname11(fileURLToPath5(import.meta.url));
17360
17671
  return firstExistingDir([process.env.FRIEND_PLUGIN_CATALOG, resolve10(here2, "plugins"), resolve10(here2, "../../../../plugins")]) ?? resolve10(here2, "plugins");
17361
17672
  }
17362
- var count = (n, word2) => `${n} ${word2}${n === 1 ? "" : "s"}`;
17673
+ var count2 = (n2, word2) => `${n2} ${word2}${n2 === 1 ? "" : "s"}`;
17363
17674
  async function pluginCommand(args, io2, deps = {}) {
17364
17675
  const catalogDir = deps.catalogDir ?? defaultCatalog();
17365
17676
  const [sub, ...rest] = args;
@@ -17375,7 +17686,7 @@ async function pluginCommand(args, io2, deps = {}) {
17375
17686
  continue;
17376
17687
  }
17377
17688
  const m = r.manifest;
17378
- io2.out(`${m.id} ${m.name} ${m.version} [${count(m.tools.length, "tool")}, ${count(m.skills.length, "skill")}] ${m.description}`);
17689
+ io2.out(`${m.id} ${m.name} ${m.version} [${count2(m.tools.length, "tool")}, ${count2(m.skills.length, "skill")}] ${m.description}`);
17379
17690
  }
17380
17691
  return 0;
17381
17692
  }
@@ -17814,9 +18125,9 @@ function pick(options, typed) {
17814
18125
  if (/^\d+$/.test(a)) return options[Number(a) - 1];
17815
18126
  return options.find((o) => o.key.toLowerCase() === a || o.label.toLowerCase() === a || o.label.toLowerCase().split(/\s+/)[0] === a);
17816
18127
  }
17817
- function heading(ui, n, total, title2) {
18128
+ function heading(ui, n2, total, title2) {
17818
18129
  ui.io.out("");
17819
- ui.io.out(`\u2500\u2500 Step ${n} of ${total} \xB7 ${title2} ${"\u2500".repeat(Math.max(2, 52 - title2.length))}`);
18130
+ ui.io.out(`\u2500\u2500 Step ${n2} of ${total} \xB7 ${title2} ${"\u2500".repeat(Math.max(2, 52 - title2.length))}`);
17820
18131
  }
17821
18132
  async function choose(ui, question, options, suggested) {
17822
18133
  const { io: io2 } = ui;
@@ -18194,7 +18505,7 @@ async function network(c) {
18194
18505
  saveConfig(cfg);
18195
18506
  const u = urlsFor(cfg.bind, cfg.port, void 0, cfg.https);
18196
18507
  io2.out(`Friend will be at ${u.local}`);
18197
- for (const n of u.network) io2.out(`and on your network at ${n}`);
18508
+ for (const n2 of u.network) io2.out(`and on your network at ${n2}`);
18198
18509
  if (where === "lan" && !cfg.https) io2.out("Note: the microphone and camera only work on this PC (or over HTTPS). Everything else works from the other devices. Later: friend lan https on");
18199
18510
  return "done";
18200
18511
  }
@@ -18361,12 +18672,60 @@ async function autostartCommand(args, io2, autostart = real(io2)) {
18361
18672
  }
18362
18673
  }
18363
18674
 
18675
+ // ../agent-cli/src/commands/usage.ts
18676
+ var USAGE4 = "Usage: friend usage [--days N] | friend usage clear";
18677
+ var n = (v) => v.toLocaleString("en-US");
18678
+ var row = (label4, input, output, turns, width) => `${label4.padEnd(width)} ${n(input).padStart(12)} ${n(output).padStart(12)} ${String(turns).padStart(6)}`;
18679
+ async function usageCommand(args, io2, log) {
18680
+ let days = 7;
18681
+ let clear = false;
18682
+ for (let i = 0; i < args.length; i++) {
18683
+ const a = args[i];
18684
+ if (a === "clear") clear = true;
18685
+ else if (a === "--days" && /^\d+$/.test(args[i + 1] ?? "")) days = Number(args[++i]);
18686
+ else {
18687
+ io2.err(USAGE4);
18688
+ return 1;
18689
+ }
18690
+ }
18691
+ const db = log ? null : openDb(io2.dataDir);
18692
+ try {
18693
+ const l = log ?? new UsageLog(db);
18694
+ if (clear) {
18695
+ io2.out(`Removed ${l.clear()} saved counts.`);
18696
+ return 0;
18697
+ }
18698
+ const s = l.summary(days);
18699
+ if (s.total.turns === 0) {
18700
+ io2.out(`Nothing used in the last ${s.range} days.`);
18701
+ io2.out("Friend keeps counts only (no words), once a model has answered.");
18702
+ return 0;
18703
+ }
18704
+ io2.out(`Tokens used in the last ${s.range} days`);
18705
+ io2.out("");
18706
+ io2.out(`${"Day".padEnd(10)} ${"Input".padStart(12)} ${"Output".padStart(12)} ${"Turns".padStart(6)}`);
18707
+ for (const d of s.days) io2.out(row(d.day, d.input, d.output, d.turns, 10));
18708
+ io2.out(row("Total", s.total.input, s.total.output, s.total.turns, 10));
18709
+ io2.out("");
18710
+ io2.out("By model");
18711
+ for (const m of s.byModel) io2.out(row(`${m.provider} / ${m.model}`, m.input, m.output, m.turns, 28));
18712
+ io2.out("");
18713
+ io2.out("By where it came from");
18714
+ for (const m of s.bySource) io2.out(row(m.source, m.input, m.output, m.turns, 28));
18715
+ io2.out("");
18716
+ io2.out("Friend keeps counts only (no words). Remove them with: friend usage clear");
18717
+ return 0;
18718
+ } finally {
18719
+ db?.close();
18720
+ }
18721
+ }
18722
+
18364
18723
  // ../agent-cli/src/commands/daemon.ts
18365
18724
  import { spawn as spawn11, spawnSync as spawnSync2 } from "node:child_process";
18366
18725
  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";
18367
18726
  import { connect } from "node:net";
18368
18727
  import { join as join24 } from "node:path";
18369
- var USAGE4 = "Usage: friend daemon start | stop | restart | status";
18728
+ var USAGE5 = "Usage: friend daemon start | stop | restart | status";
18370
18729
  var MAX_LOG_BYTES = 5 * 1024 * 1024;
18371
18730
  var sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
18372
18731
  var realDeps = {
@@ -18471,7 +18830,7 @@ async function start(io2, deps) {
18471
18830
  for (let i = 0; i < 60; i++) {
18472
18831
  if (await deps.portOpen(cfg.port, 300)) {
18473
18832
  io2.out(`Friend is running in the background (pid ${pid}) at ${urls.local}`);
18474
- for (const n of urls.network) io2.out(`Also on your network: ${n}`);
18833
+ for (const n2 of urls.network) io2.out(`Also on your network: ${n2}`);
18475
18834
  io2.out(`Stop it with: friend daemon stop (log: ${log})`);
18476
18835
  return 0;
18477
18836
  }
@@ -18504,7 +18863,7 @@ async function status(io2, deps) {
18504
18863
  if (r) {
18505
18864
  io2.out(`Friend is running in the background (pid ${r.pid}, for ${since(deps.now() - r.startedAt)}).`);
18506
18865
  io2.out(`Address: ${urls.local}`);
18507
- for (const n of urls.network) io2.out(`Also on your network: ${n}`);
18866
+ for (const n2 of urls.network) io2.out(`Also on your network: ${n2}`);
18508
18867
  io2.out(`Log: ${logFileOf(io2.dataDir)}`);
18509
18868
  return 0;
18510
18869
  }
@@ -18525,7 +18884,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
18525
18884
  case "status":
18526
18885
  return status(io2, deps);
18527
18886
  default:
18528
- io2.err(USAGE4);
18887
+ io2.err(USAGE5);
18529
18888
  return 1;
18530
18889
  }
18531
18890
  }
@@ -18536,7 +18895,7 @@ import { appendFileSync } from "node:fs";
18536
18895
  import { join as join25 } from "node:path";
18537
18896
  var PACKAGE2 = "friend-assistant";
18538
18897
  var real2 = {
18539
- current: () => true ? "0.1.45" : "development",
18898
+ current: () => true ? "0.1.47" : "development",
18540
18899
  latest: () => new Promise((resolve13) => {
18541
18900
  const child = crossSpawn5("npm", ["view", PACKAGE2, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
18542
18901
  let out = "";
@@ -18562,8 +18921,8 @@ var real2 = {
18562
18921
  daemon: realDeps
18563
18922
  };
18564
18923
  var newer = (a, b) => {
18565
- const n = (v) => v.split(/[.-]/).slice(0, 3).map((x2) => Number(x2) || 0);
18566
- const [x, y] = [n(a), n(b)];
18924
+ const n2 = (v) => v.split(/[.-]/).slice(0, 3).map((x2) => Number(x2) || 0);
18925
+ const [x, y] = [n2(a), n2(b)];
18567
18926
  for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
18568
18927
  return false;
18569
18928
  };
@@ -18744,16 +19103,16 @@ async function secretCommand(kind, io2) {
18744
19103
  io2.err("There is no owner yet. Run: friend setup");
18745
19104
  return 1;
18746
19105
  }
18747
- const label3 = kind === "password" ? "password" : "PIN";
18748
- const first2 = await io2.ask(`New ${label3} (${kind === "password" ? "at least 8 characters" : "at least 6 digits"}): `, { hidden: true });
19106
+ const label4 = kind === "password" ? "password" : "PIN";
19107
+ const first2 = await io2.ask(`New ${label4} (${kind === "password" ? "at least 8 characters" : "at least 6 digits"}): `, { hidden: true });
18749
19108
  const problem = Auth.checkNewSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
18750
19109
  if (problem) {
18751
19110
  io2.err(problem);
18752
19111
  return 1;
18753
19112
  }
18754
- const again = await io2.ask(`Type the new ${label3} again: `, { hidden: true });
19113
+ const again = await io2.ask(`Type the new ${label4} again: `, { hidden: true });
18755
19114
  if (again !== first2) {
18756
- io2.err(`The two ${label3}s are not the same. Nothing was changed.`);
19115
+ io2.err(`The two ${label4}s are not the same. Nothing was changed.`);
18757
19116
  return 1;
18758
19117
  }
18759
19118
  await auth.resetSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
@@ -18774,7 +19133,7 @@ async function statusCommand3(io2) {
18774
19133
  io2.out(`Data folder: ${cfg.dataDir}`);
18775
19134
  io2.out(`Owner set up: ${owner}`);
18776
19135
  io2.out(`Listens on: ${cfg.bind}:${cfg.port}`);
18777
- for (const n of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n}`);
19136
+ for (const n2 of urlsFor(cfg.bind, cfg.port, void 0, cfg.https).network) io2.out(`Network URL: ${n2}`);
18778
19137
  io2.out(`Public URL: ${cfg.publicUrl ?? "(none)"}`);
18779
19138
  io2.out(`Workspace: ${cfg.roots.length ? cfg.roots.join(", ") : "(none, every file action asks first)"}`);
18780
19139
  io2.out(`Model: ${cfg.model}`);
@@ -18783,7 +19142,7 @@ async function statusCommand3(io2) {
18783
19142
  }
18784
19143
 
18785
19144
  // ../agent-cli/src/cli.ts
18786
- var USAGE5 = `Usage: friend <command>
19145
+ var USAGE6 = `Usage: friend <command>
18787
19146
 
18788
19147
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
18789
19148
  doctor check what this PC has and what is missing (changes nothing)
@@ -18793,6 +19152,7 @@ var USAGE5 = `Usage: friend <command>
18793
19152
  start run the gateway and a local device node
18794
19153
  daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
18795
19154
  upgrade [--force] stop the background Friend, install the newest version, start it again
19155
+ usage [--days N]|clear tokens used, day by day (counts only, never words)
18796
19156
  autostart on|off|status start Friend by itself when you sign in to this computer (Windows, macOS, Linux)
18797
19157
  pair print a one-time code to connect another PC
18798
19158
  node --url <ws-url> --code <code> [--roots a,b] [--name pc]
@@ -18826,6 +19186,8 @@ async function run(argv, io2) {
18826
19186
  return await daemonCommand(rest, io2);
18827
19187
  case "upgrade":
18828
19188
  return await upgradeCommand(rest, io2);
19189
+ case "usage":
19190
+ return await usageCommand(rest, io2);
18829
19191
  case "autostart":
18830
19192
  return await autostartCommand(rest, io2);
18831
19193
  case "pair":
@@ -18843,12 +19205,12 @@ async function run(argv, io2) {
18843
19205
  case "help":
18844
19206
  case "--help":
18845
19207
  case "-h":
18846
- io2.out(USAGE5);
19208
+ io2.out(USAGE6);
18847
19209
  return 0;
18848
19210
  case "version":
18849
19211
  case "--version":
18850
19212
  case "-v":
18851
- io2.out(true ? "0.1.45" : "development");
19213
+ io2.out(true ? "0.1.47" : "development");
18852
19214
  return 0;
18853
19215
  case "lan":
18854
19216
  return await lanCommand(rest, io2);
@@ -18867,7 +19229,7 @@ async function run(argv, io2) {
18867
19229
  case "config":
18868
19230
  return await configCommand(rest, io2);
18869
19231
  default:
18870
- io2.err(USAGE5);
19232
+ io2.err(USAGE6);
18871
19233
  return 1;
18872
19234
  }
18873
19235
  } catch (e) {