friend-assistant 0.1.46 → 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/README.md CHANGED
@@ -141,6 +141,8 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
141
141
  | `openai.url`, `openai.model`, `openai.apiKey`, `openai.vision` | An OpenAI-compatible server: its address (ending in `/v1`), the model name it knows, its API key (or empty), and whether the model can see pictures. For OmniRoute (https://github.com/diegosouzapw/OmniRoute) run `npm install -g omniroute` and `omniroute`, then `friend config set provider openai` (the defaults are OmniRoute's: `http://localhost:20128/v1` and model `auto`). Also works with LM Studio, LiteLLM, vLLM and OpenRouter. |
142
142
  | `voice.hindiVoice`, `voice.marathiVoice` | The voices that read Hindi and Marathi (Devanagari) text: `hi_IN-priyamvada-medium`, `hi_IN-pratham-medium`, `hi_IN-rohan-medium`, `mr_IN-google-medium`. Empty makes the main voice read that language. See "Hindi, Marathi and English together". |
143
143
  | `updates.auto` | Look for a newer Friend shortly after it starts and every few hours, and install it by itself while Friend is not busy (only a Friend running in the background restarts itself). Off: Friend only tells you a new version is out, and you run `friend upgrade` or press Update now in Settings → Startup. |
144
+ | `session.autoCompact` | With Claude Code, Friend keeps one long conversation and re-reads all of it for every answer. On (default): when it grows past `session.compactAtTokens`, Friend writes short notes on it and starts a fresh conversation from them before the next answer. Off: it only tidies when you press Tidy up now. |
145
+ | `session.compactAtTokens` | How big the conversation may grow before it is tidied up by itself, in tokens (50000 to 800000, default 100000). A fresh conversation already starts near 36000. |
144
146
  | `voice.enabled`, `voice.sttModel`, `voice.ttsVoice`, `voice.language`, `voice.speakTyped`, `voice.duck`, `voice.duckPercent`, `voice.accept`, `voice.challenge` | Speech on or off, the listening model, the speaking voice, a forced language, speaking typed replies, turning music and video down while you talk and while Friend speaks (and how far, in percent), and the two thresholds for recognising your voice. |
145
147
  | `bind`, `port` | Where Friend listens (easier: `friend lan`). |
146
148
  | `https` | `true` serves a secure `https://` address with a certificate Friend makes for itself, so a phone may use the microphone (easier: `friend lan https on`). |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.46",
4
- "builtAt": "2026-10-13T11:17:41.438Z"
3
+ "version": "0.1.47",
4
+ "builtAt": "2026-10-13T12:59:11.993Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -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();
@@ -2573,6 +2674,7 @@ var Agent = class {
2573
2674
  }
2574
2675
  this.busy = true;
2575
2676
  const spent = { input: 0, output: 0 };
2677
+ let reported = 0;
2576
2678
  this.scenesThisTurn = 0;
2577
2679
  const stage = (cmd) => {
2578
2680
  d.stage.apply(cmd);
@@ -2593,6 +2695,7 @@ var Agent = class {
2593
2695
  let lastAnswer = "";
2594
2696
  try {
2595
2697
  if (!isolated) repairDangling(d.db);
2698
+ if (!isolated) await this.tidyIfBig(emit, abort.signal);
2596
2699
  const userMsg = { role: "user", content: [{ type: "text", text: text3 }] };
2597
2700
  keep(userMsg);
2598
2701
  const earlier = member ? earlierOf(this.memberTalk.get(ctx.person.id), d.tasks?.now() ?? Date.now()) : [];
@@ -2635,6 +2738,7 @@ var Agent = class {
2635
2738
  if (ev.type === "usage") {
2636
2739
  spent.input += ev.input;
2637
2740
  spent.output += ev.output;
2741
+ if (ev.context) reported = ev.context;
2638
2742
  } else if (ev.type === "text_delta") {
2639
2743
  if (!assistantText) {
2640
2744
  stage({ cmd: "presence", state: "speaking" });
@@ -2695,6 +2799,7 @@ var Agent = class {
2695
2799
  d.audit.log("system", "agent.error", { message: e.message });
2696
2800
  emit({ type: "caption.delta", text: "Sorry, something went wrong on my side." });
2697
2801
  } finally {
2802
+ if (reported > 0 && !isolated) this.context = reported;
2698
2803
  if (d.usage && (spent.input > 0 || spent.output > 0)) d.usage({ source: ctx.source ?? "other", ...spent });
2699
2804
  if (member && lastAnswer) {
2700
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 }] }];
@@ -3352,6 +3457,12 @@ function sanitizeLook(raw) {
3352
3457
  };
3353
3458
  }
3354
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
+ });
3355
3466
  var sanitizeUpdates = (u) => ({ auto: typeof u?.auto === "boolean" ? u.auto : true });
3356
3467
  var DEFAULT_VOICE = {
3357
3468
  enabled: true,
@@ -3419,6 +3530,7 @@ var defaults = (dataDir) => ({
3419
3530
  claudeCode: { ...DEFAULT_CLAUDE_CODE },
3420
3531
  voice: { ...DEFAULT_VOICE },
3421
3532
  updates: sanitizeUpdates(void 0),
3533
+ session: sanitizeSession(void 0),
3422
3534
  view: "companion",
3423
3535
  look: { ...DEFAULT_LOOK },
3424
3536
  ownerName: "",
@@ -3466,6 +3578,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3466
3578
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
3467
3579
  voice: sanitizeVoice({ ...DEFAULT_VOICE, ...stored.voice }),
3468
3580
  updates: sanitizeUpdates(stored.updates),
3581
+ session: sanitizeSession(stored.session),
3469
3582
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
3470
3583
  look: sanitizeLook(stored.look),
3471
3584
  ownerName: typeof stored.ownerName === "string" ? stored.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "",
@@ -7124,7 +7237,7 @@ var MAX_SEND = 100 * 1024 * 1024;
7124
7237
  var MAX_IMAGE = 40 * 1024 * 1024;
7125
7238
  var MAX_VIDEO = 8 * 1024 * 1024 * 1024;
7126
7239
  var MAX_PDF = 300 * 1024 * 1024;
7127
- var MAX_TEXT = 2e5;
7240
+ var MAX_TEXT2 = 2e5;
7128
7241
  var MAX_CSV_ROWS = 200;
7129
7242
  function parseCsv(text3) {
7130
7243
  const rows3 = [];
@@ -7247,17 +7360,17 @@ var MediaStore = class {
7247
7360
  const handle = await fs.open(real3, "r");
7248
7361
  let text3;
7249
7362
  try {
7250
- const buf = Buffer.alloc(Math.min(stat2.size, MAX_TEXT));
7363
+ const buf = Buffer.alloc(Math.min(stat2.size, MAX_TEXT2));
7251
7364
  await handle.read(buf, 0, buf.length, 0);
7252
7365
  text3 = buf.toString("utf8");
7253
7366
  } finally {
7254
7367
  await handle.close();
7255
7368
  }
7256
7369
  if (text3.includes("\0")) return { ok: false, error: "That file is not text, so it cannot be shown." };
7257
- const cut = stat2.size > MAX_TEXT;
7370
+ const cut = stat2.size > MAX_TEXT2;
7258
7371
  const note = cut ? `
7259
7372
 
7260
- [Only the first ${MAX_TEXT} characters are shown.]` : "";
7373
+ [Only the first ${MAX_TEXT2} characters are shown.]` : "";
7261
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." : ""}` };
7262
7375
  if (ext === "csv") {
7263
7376
  const rows3 = parseCsv(text3);
@@ -9808,7 +9921,7 @@ var BrokerError = class extends Error {
9808
9921
  var MOVERS = 3;
9809
9922
  var TOP = 5;
9810
9923
  var r2 = (n2) => Number.isFinite(n2) ? Math.round((n2 + Number.EPSILON) * 100) / 100 : 0;
9811
- function summarize(holdings) {
9924
+ function summarize2(holdings) {
9812
9925
  let value = 0;
9813
9926
  let invested = 0;
9814
9927
  let yesterday = 0;
@@ -9908,7 +10021,7 @@ function buildReport(input) {
9908
10021
  else if (snap.holdings.data.length === 0) out.push("No holdings.");
9909
10022
  else {
9910
10023
  const holdings = snap.holdings.data;
9911
- const s = summarize(holdings);
10024
+ const s = summarize2(holdings);
9912
10025
  out.push(`Portfolio: ${fmt(s.value)} (invested ${fmt(s.invested)})`);
9913
10026
  out.push(`Profit/loss: ${signed(s.pnl)} (${signedPct(s.pnlPct)})`);
9914
10027
  out.push(`Today: ${signed(s.dayChange)} (${signedPct(s.dayChangePct)})`);
@@ -11727,6 +11840,23 @@ function createGateway(deps) {
11727
11840
  audit.log("owner", "usage.cleared", { removed });
11728
11841
  return { removed };
11729
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
+ });
11730
11860
  const chromeFailures = new SlidingWindow(20, 6e4);
11731
11861
  const chromeWss = new WebSocketServer({ noServer: true, maxPayload: MAX_DEVICE_FRAME_BYTES });
11732
11862
  if (deps.chrome) {
@@ -12599,6 +12729,8 @@ var ClaudeCodeProvider = class {
12599
12729
  id = "claude-code";
12600
12730
  vision = true;
12601
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;
12602
12734
  guests = /* @__PURE__ */ new Set();
12603
12735
  bridge;
12604
12736
  bridgeReady = null;
@@ -12641,6 +12773,25 @@ var ClaudeCodeProvider = class {
12641
12773
  if (guest) this.endGuest(conv);
12642
12774
  }
12643
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
+ }
12644
12795
  async close() {
12645
12796
  this.bridge.failPending("Friend is stopping.");
12646
12797
  this.owner.proc?.kill();
@@ -12703,7 +12854,9 @@ var ClaudeCodeProvider = class {
12703
12854
  const text3 = String(ev.result ?? turnText);
12704
12855
  if (!ev.is_error) {
12705
12856
  const u = ev.usage ?? {};
12706
- 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) } : {} };
12707
12860
  yield { type: "done" };
12708
12861
  return "ok";
12709
12862
  }
@@ -12797,6 +12950,7 @@ ${item.stderr}`;
12797
12950
  mkdirSync9(this.home, { recursive: true });
12798
12951
  if (!conv.guest) {
12799
12952
  conv.sessionId ??= this.readSession();
12953
+ this.fresh = conv.sessionId ? null : this.readFresh();
12800
12954
  await this.startBridge();
12801
12955
  this.bridge.setTools(req.tools);
12802
12956
  }
@@ -12873,11 +13027,26 @@ ${item.stderr}`;
12873
13027
  rememberSession(conv, id2) {
12874
13028
  if (conv.guest || conv.sessionId === id2) return;
12875
13029
  conv.sessionId = id2;
13030
+ this.fresh = null;
13031
+ try {
13032
+ rmSync8(this.freshFile(), { force: true });
13033
+ } catch {
13034
+ }
12876
13035
  try {
12877
13036
  writeFileSync8(this.sessionFile(), id2);
12878
13037
  } catch {
12879
13038
  }
12880
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
+ }
12881
13050
  forgetSession(conv) {
12882
13051
  conv.sessionId = null;
12883
13052
  try {
@@ -12897,7 +13066,11 @@ ${item.stderr}`;
12897
13066
  }
12898
13067
  }
12899
13068
  const blocks = [];
12900
- 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)) });
12901
13074
  const ran = /* @__PURE__ */ new Map();
12902
13075
  for (const m of msgs.slice(last + 1)) {
12903
13076
  for (const b of m.content) {
@@ -12960,6 +13133,9 @@ var FallbackProvider = class {
12960
13133
  yield ev;
12961
13134
  }
12962
13135
  }
13136
+ async resetSession(seed) {
13137
+ await Promise.all([this.primary.resetSession?.(seed), this.other.resetSession?.(seed)]);
13138
+ }
12963
13139
  async close() {
12964
13140
  await Promise.all([this.primary.close?.(), this.other.close?.()]);
12965
13141
  }
@@ -12999,6 +13175,9 @@ var SwitchableProvider = class {
12999
13175
  this.current = next;
13000
13176
  return old;
13001
13177
  }
13178
+ async resetSession(seed) {
13179
+ await this.current.resetSession?.(seed);
13180
+ }
13002
13181
  async close() {
13003
13182
  await this.current.close?.();
13004
13183
  }
@@ -13145,7 +13324,8 @@ var SettingsPatch = z11.object({
13145
13324
  duck: z11.boolean(),
13146
13325
  duckPercent: z11.number().int().min(0).max(100)
13147
13326
  }).partial().strict(),
13148
- 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()
13149
13329
  }).partial().strict();
13150
13330
  function settingsView(c) {
13151
13331
  return {
@@ -13209,7 +13389,8 @@ function settingsView(c) {
13209
13389
  duck: c.voice.duck,
13210
13390
  duckPercent: c.voice.duckPercent
13211
13391
  },
13212
- updates: { auto: c.updates.auto }
13392
+ updates: { auto: c.updates.auto },
13393
+ session: { autoCompact: c.session.autoCompact, compactAtTokens: c.session.compactAtTokens }
13213
13394
  };
13214
13395
  }
13215
13396
  var SettingsError = class extends Error {
@@ -13367,6 +13548,7 @@ function applySettings(config, raw) {
13367
13548
  }
13368
13549
  if (p.claudeCode) Object.assign(config.claudeCode, p.claudeCode);
13369
13550
  if (p.updates) Object.assign(config.updates, p.updates);
13551
+ if (p.session) Object.assign(config.session, p.session);
13370
13552
  if (p.voice) {
13371
13553
  const safe = next.voice;
13372
13554
  for (const k of Object.keys(p.voice)) config.voice[k] = safe[k];
@@ -14048,7 +14230,7 @@ function currentSelf() {
14048
14230
  // ../server/src/system/npm.ts
14049
14231
  import { spawn as spawn7 } from "node:child_process";
14050
14232
  var PACKAGE = "friend-assistant";
14051
- var runningVersion = () => true ? "0.1.46" : "development";
14233
+ var runningVersion = () => true ? "0.1.47" : "development";
14052
14234
  async function npmLatest(fetchFn = fetch, timeoutMs = 1e4) {
14053
14235
  try {
14054
14236
  const res = await fetchFn(`https://registry.npmjs.org/${PACKAGE}/latest`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
@@ -15172,6 +15354,7 @@ async function createApp(opts) {
15172
15354
  admin,
15173
15355
  provider,
15174
15356
  usage: (e) => usageLog.record({ ...e, ...modelInUse() }),
15357
+ session: { auto: () => ({ on: config.session.autoCompact, limit: config.session.compactAtTokens }) },
15175
15358
  policy,
15176
15359
  approvals,
15177
15360
  devices,
@@ -15222,7 +15405,7 @@ async function createApp(opts) {
15222
15405
  const got = await brokerHub.snapshot("zerodha");
15223
15406
  if (!got.ok) return got.sessionExpired ? "Zerodha needs you to log in first." : got.error;
15224
15407
  if (!got.snapshot.holdings.ok) return "I could not read your holdings just now.";
15225
- 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 });
15226
15409
  },
15227
15410
  WeatherIntent: async (_locale, slots) => {
15228
15411
  if (!slots.city) return "Tell me which city, for example: what is the weather in Pune.";
@@ -15578,6 +15761,20 @@ async function createApp(opts) {
15578
15761
  chrome: chromeRemoteApi,
15579
15762
  system: systemApi,
15580
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
+ },
15581
15778
  video: {
15582
15779
  stream: (id2) => videoHub.streamOf(id2),
15583
15780
  status: async () => videoHub.status() ?? await videoHub.refresh(),
@@ -18698,7 +18895,7 @@ import { appendFileSync } from "node:fs";
18698
18895
  import { join as join25 } from "node:path";
18699
18896
  var PACKAGE2 = "friend-assistant";
18700
18897
  var real2 = {
18701
- current: () => true ? "0.1.46" : "development",
18898
+ current: () => true ? "0.1.47" : "development",
18702
18899
  latest: () => new Promise((resolve13) => {
18703
18900
  const child = crossSpawn5("npm", ["view", PACKAGE2, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
18704
18901
  let out = "";
@@ -19013,7 +19210,7 @@ async function run(argv, io2) {
19013
19210
  case "version":
19014
19211
  case "--version":
19015
19212
  case "-v":
19016
- io2.out(true ? "0.1.46" : "development");
19213
+ io2.out(true ? "0.1.47" : "development");
19017
19214
  return 0;
19018
19215
  case "lan":
19019
19216
  return await lanCommand(rest, io2);