friend-assistant 0.1.24 → 0.1.26

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
@@ -1,169 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire as __friendCreateRequire } from 'node:module'; const require = __friendCreateRequire(import.meta.url);
3
3
 
4
- // ../server/src/brokers/types.ts
5
- var BrokerError = class extends Error {
6
- constructor(message, kind) {
7
- super(message);
8
- this.kind = kind;
9
- this.name = "BrokerError";
10
- }
11
- kind;
12
- };
13
-
14
- // ../server/src/brokers/portfolio.ts
15
- var MOVERS = 3;
16
- var TOP = 5;
17
- var r2 = (n) => Number.isFinite(n) ? Math.round((n + Number.EPSILON) * 100) / 100 : 0;
18
- function summarize(holdings) {
19
- let value = 0;
20
- let invested = 0;
21
- let yesterday = 0;
22
- for (const h of holdings) {
23
- value += h.quantity * h.lastPrice;
24
- invested += h.quantity * h.avgPrice;
25
- yesterday += h.quantity * h.closePrice;
26
- }
27
- const dayChange = value - yesterday;
28
- const pnl = value - invested;
29
- const move = (h) => h.quantity * (h.lastPrice - h.closePrice);
30
- const worth = (h) => h.quantity * h.lastPrice;
31
- const top = [...holdings].sort((a, b) => worth(b) - worth(a)).slice(0, TOP).map((h) => ({ symbol: h.symbol, pct: value > 0 ? r2(worth(h) / value * 100) : 0 }));
32
- return {
33
- value: r2(value),
34
- invested: r2(invested),
35
- pnl: r2(pnl),
36
- pnlPct: invested > 0 ? r2(pnl / invested * 100) : 0,
37
- dayChange: r2(dayChange),
38
- dayChangePct: yesterday > 0 ? r2(dayChange / yesterday * 100) : 0,
39
- gainers: holdings.filter((h) => move(h) > 0).sort((a, b) => move(b) - move(a)).slice(0, MOVERS),
40
- losers: holdings.filter((h) => move(h) < 0).sort((a, b) => move(a) - move(b)).slice(0, MOVERS),
41
- topHoldingPct: top[0]?.pct ?? 0,
42
- top
43
- };
44
- }
45
- var Portfolio = class {
46
- constructor(provider, clock = Date.now, ttlMs = 3e4) {
47
- this.provider = provider;
48
- this.clock = clock;
49
- this.ttlMs = ttlMs;
50
- }
51
- provider;
52
- clock;
53
- ttlMs;
54
- cached = null;
55
- inflight = null;
56
- invalidate() {
57
- this.cached = null;
58
- }
59
- snapshot() {
60
- if (this.cached && this.clock() - this.cached.at < this.ttlMs) return Promise.resolve(this.cached);
61
- this.inflight ??= this.read().finally(() => {
62
- this.inflight = null;
63
- });
64
- return this.inflight;
65
- }
66
- async read() {
67
- const at = this.clock();
68
- const caps = this.provider.capabilities;
69
- const [holdings, positions, funds, orders] = await Promise.all([
70
- section(caps.holdings, () => this.provider.holdings()),
71
- section(caps.positions, () => this.provider.positions()),
72
- section(caps.funds, () => this.provider.funds()),
73
- section(caps.orders, () => this.provider.orders())
74
- ]);
75
- this.cached = { at, holdings, positions, funds, orders };
76
- return this.cached;
77
- }
78
- };
79
- async function section(supported, read) {
80
- if (!supported) return { ok: false, error: "Not available from this broker.", kind: "other" };
81
- try {
82
- return { ok: true, data: await read() };
83
- } catch (e) {
84
- return e instanceof BrokerError ? { ok: false, error: e.message, kind: e.kind } : { ok: false, error: "Could not read this.", kind: "other" };
4
+ // ../server/src/brokers/money.ts
5
+ var DEFAULT_MONEY = { symbol: "\u20B9", locale: "en-IN" };
6
+ var formats = /* @__PURE__ */ new Map();
7
+ function formatterFor(locale2) {
8
+ let f = formats.get(locale2);
9
+ if (!f) {
10
+ try {
11
+ f = new Intl.NumberFormat(locale2, { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true });
12
+ } catch {
13
+ f = formatterFor(DEFAULT_MONEY.locale);
14
+ }
15
+ formats.set(locale2, f);
85
16
  }
17
+ return f;
86
18
  }
87
-
88
- // ../server/src/brokers/report.ts
89
- function formatInr(n) {
19
+ function formatMoney(n, o = DEFAULT_MONEY) {
90
20
  const value = Number.isFinite(n) ? n : 0;
91
21
  const fixed = Math.abs(value).toFixed(2);
92
- const [whole, cents] = fixed.split(".");
93
- const grouped = whole.length <= 3 ? whole : `${whole.slice(0, -3).replace(/\B(?=(\d{2})+(?!\d))/g, ",")},${whole.slice(-3)}`;
94
22
  const negative = value < 0 && fixed !== "0.00";
95
- return `${negative ? "-" : ""}\u20B9${grouped}.${cents}`;
96
- }
97
- var signedInr = (n) => n > 4e-3 ? `+${formatInr(n)}` : formatInr(n);
98
- var signedPct = (p) => {
99
- const x = Number.isFinite(p) ? p : 0;
100
- const text2 = Math.abs(x).toFixed(2);
101
- return text2 === "0.00" ? "0.00%" : `${x > 0 ? "+" : "-"}${text2}%`;
102
- };
103
- var pct1 = (p) => Math.abs(p).toFixed(1);
104
- var TITLE = { morning: "Morning report", close: "Market close report", custom: "Portfolio report" };
105
- var WHY = { rate: "rate limit", session: "login expired", network: "network problem", auth: "login problem" };
106
- var missing = (label2, s) => `${label2}: not available (${WHY[s.kind] ?? s.error.slice(0, 60)})`;
107
- var CONCENTRATED = 30;
108
- var BIG_DROP = 5;
109
- function title(kind, broker, now, zone) {
110
- const parts = Object.fromEntries(
111
- new Intl.DateTimeFormat("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now).map((p) => [p.type, p.value])
112
- );
113
- return `${TITLE[kind]} - ${broker} - ${parts.weekday} ${parts.day} ${parts.month}, ${parts.hour}:${parts.minute}`;
114
- }
115
- var dayMovePct = (h) => h.closePrice > 0 ? (h.lastPrice - h.closePrice) / h.closePrice * 100 : 0;
116
- function buildReport(input) {
117
- const { snapshot: snap, caps } = input;
118
- const out = [title(input.kind, input.broker, input.now, input.zone ?? "Asia/Kolkata"), ""];
119
- if (caps.holdings) {
120
- if (!snap.holdings.ok) out.push(missing("Holdings", snap.holdings));
121
- else if (snap.holdings.data.length === 0) out.push("No holdings.");
122
- else {
123
- const holdings = snap.holdings.data;
124
- const s = summarize(holdings);
125
- out.push(`Portfolio: ${formatInr(s.value)} (invested ${formatInr(s.invested)})`);
126
- out.push(`Profit/loss: ${signedInr(s.pnl)} (${signedPct(s.pnlPct)})`);
127
- out.push(`Today: ${signedInr(s.dayChange)} (${signedPct(s.dayChangePct)})`);
128
- const mover = (h) => `- ${h.symbol} ${signedInr(h.quantity * (h.lastPrice - h.closePrice))} (${signedPct(dayMovePct(h))})`;
129
- if (s.gainers.length) out.push("", "Up today:", ...s.gainers.map(mover));
130
- if (s.losers.length) out.push("", "Down today:", ...s.losers.map(mover));
131
- const flags = [];
132
- const top = s.top[0];
133
- if (top && s.topHoldingPct > CONCENTRATED) flags.push(`- ${top.symbol} is ${pct1(s.topHoldingPct)}% of the portfolio.`);
134
- for (const h of holdings) if (dayMovePct(h) < -BIG_DROP) flags.push(`- ${h.symbol} is down ${pct1(dayMovePct(h))}% today.`);
135
- if (flags.length) out.push("", "Worth a look:", ...flags);
136
- }
137
- }
138
- if (caps.positions) {
139
- out.push("");
140
- if (!snap.positions.ok) out.push(missing("Positions", snap.positions));
141
- else {
142
- const open = snap.positions.data.filter((p) => p.quantity !== 0);
143
- if (open.length === 0) out.push("No open positions.");
144
- else {
145
- out.push("Open positions:");
146
- for (const p of open) out.push(`- ${p.symbol} ${p.product} ${p.quantity}${p.quantity < 0 ? " (short)" : ""} @ ${formatInr(p.avgPrice)}, P&L ${signedInr(p.pnl)}`);
147
- }
148
- }
149
- }
150
- if (caps.funds) {
151
- out.push("");
152
- if (!snap.funds.ok) out.push(missing("Funds", snap.funds));
153
- else out.push(`Funds: ${formatInr(snap.funds.data.available)} available, ${formatInr(snap.funds.data.used)} used`);
154
- }
155
- if (caps.orders && snap.orders.ok && snap.orders.data.length > 0) out.push(`Orders today: ${snap.orders.data.length}`);
156
- return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
23
+ return `${negative ? "-" : ""}${o.symbol}${formatterFor(o.locale).format(Number(fixed))}`;
157
24
  }
158
25
 
159
26
  // ../server/src/brokers/tooltext.ts
160
27
  var MAX_ROWS = 40;
161
- var WHY2 = { rate: "Zerodha's rate limit was hit, try again in a minute", session: "the login has ended", network: "Zerodha could not be reached", auth: "there is a login problem" };
28
+ var WHY = { rate: "Zerodha's rate limit was hit, try again in a minute", session: "the login has ended", network: "Zerodha could not be reached", auth: "there is a login problem" };
162
29
  var asOf = (at, zone) => new Intl.DateTimeFormat("en-GB", { timeZone: zone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(at));
163
30
  var rows = (items, line) => [...items.slice(0, MAX_ROWS).map(line), ...items.length > MAX_ROWS ? [`...and ${items.length - MAX_ROWS} more`] : []];
164
- function partText(part, snap, zone) {
31
+ function partText(part, snap, zone, money = DEFAULT_MONEY) {
32
+ const formatInr = (n) => formatMoney(n, money);
165
33
  const section2 = snap[part];
166
- if (!section2.ok) return `Could not read the ${part}: ${WHY2[section2.kind] ?? section2.error.slice(0, 100)}.`;
34
+ if (!section2.ok) return `Could not read the ${part}: ${WHY[section2.kind] ?? section2.error.slice(0, 100)}.`;
167
35
  const head = `Zerodha ${part} as of ${asOf(snap.at, zone)}:`;
168
36
  switch (part) {
169
37
  case "holdings": {
@@ -563,6 +431,19 @@ function matcherMatches(m, args) {
563
431
  return true;
564
432
  }
565
433
 
434
+ // ../server/src/alexa/constants.ts
435
+ var ALEXA_LOCALES = ["en-IN", "hi-IN"];
436
+ var ALEXA_LIMITS = {
437
+ answerSeconds: { min: 2, max: 7.5, def: 6.5 },
438
+ holdMinutes: { min: 1, max: 60, def: 10 },
439
+ permissionSeconds: { min: 30, max: 600, def: 120 },
440
+ requestsPerMinute: { min: 10, max: 600, def: 60 },
441
+ maxDevices: { min: 1, max: 50, def: 10 }
442
+ };
443
+ var TOOL_NAME = /^[a-z][a-z0-9._-]{1,60}$/;
444
+ var NOT_BY_VOICE = ["shell.", "fs.", "code.", "settings.", "connectors.", "plugins.", "plugin.", "integrations.", "camera.", "screen.", "mcp."];
445
+ var isNeverByVoice = (tool) => NOT_BY_VOICE.some((p) => tool.startsWith(p));
446
+
566
447
  // ../server/src/policy/engine.ts
567
448
  var ASK_EVERY_TIME = /* @__PURE__ */ new Set(["camera.snap"]);
568
449
  var MEMBER_TOOLS = /* @__PURE__ */ new Set(["weather.get", "stage.scene", "stage.show", "stage.update", "stage.hide", "stage.presence", "stage.mood"]);
@@ -586,7 +467,6 @@ var VOICE_TOOLS = /* @__PURE__ */ new Set([
586
467
  "stage.mood",
587
468
  "stage.presence"
588
469
  ]);
589
- var NOT_BY_VOICE = ["shell.", "fs.", "code.", "settings.", "connectors.", "plugins.", "plugin.", "integrations.", "camera.", "screen.", "mcp."];
590
470
  var EDIT_TOOLS = /* @__PURE__ */ new Set(["fs.list", "fs.read", "fs.write", "code.run"]);
591
471
  var isAdminTool = (tool) => tool.startsWith("connectors.") || tool.startsWith("plugins.") || tool.startsWith("integrations.");
592
472
  var PolicyEngine = class {
@@ -607,7 +487,9 @@ var PolicyEngine = class {
607
487
  if (input.trust === "member") return MEMBER_TOOLS.has(input.tool) ? "allow" : "deny";
608
488
  if (input.trust === "voice-device") {
609
489
  if (VOICE_TOOLS.has(input.tool)) return "allow";
610
- if (input.baseTier === "T3" || input.baseTier === "T0" || input.baseTier === "T1" || NOT_BY_VOICE.some((p) => input.tool.startsWith(p))) return "deny";
490
+ if (input.baseTier === "T3" || isNeverByVoice(input.tool)) return "deny";
491
+ if (this.config.alexa.extraVoiceTools.includes(input.tool)) return "allow";
492
+ if (input.baseTier === "T0" || input.baseTier === "T1") return "deny";
611
493
  return "ask";
612
494
  }
613
495
  let tier = input.baseTier;
@@ -1560,7 +1442,7 @@ var BANNED = /* @__PURE__ */ new Set(["alexa", "amazon", "echo", "skill", "app",
1560
1442
  var MAX_LENGTH = 50;
1561
1443
  function invocationNameProblem(name) {
1562
1444
  const words = name.trim().split(/\s+/).filter(Boolean);
1563
- if (words.length < 2) return 'Use two or more words, like "jarvis assistant". Amazon does not allow a single word.';
1445
+ if (words.length < 1) return 'Enter the name you will say after "Alexa, ask".';
1564
1446
  if (name.length > MAX_LENGTH) return `That is too long (at most ${MAX_LENGTH} characters).`;
1565
1447
  if (!/^[a-z' ]+$/.test(name)) return "Use lower-case letters, spaces and apostrophes only. Spell numbers out as words.";
1566
1448
  const bad = words.find((w) => BANNED.has(w));
@@ -2501,7 +2383,7 @@ var Agent = class {
2501
2383
  case "portfolio.orders": {
2502
2384
  const got = await b.snapshot("zerodha");
2503
2385
  if (!got.ok) return { ok: false, text: got.sessionExpired ? ended : got.error };
2504
- return { ok: true, text: partText(name.slice("portfolio.".length), got.snapshot, zone) };
2386
+ return { ok: true, text: partText(name.slice("portfolio.".length), got.snapshot, zone, { symbol: this.deps.config.brokers.currencySymbol, locale: this.deps.config.brokers.numberLocale }) };
2505
2387
  }
2506
2388
  case "reports.list": {
2507
2389
  const all = b.schedules.list();
@@ -2807,15 +2689,35 @@ function sanitizeAlexa(raw) {
2807
2689
  const label2 = typeof x.name === "string" ? x.name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "";
2808
2690
  return [{ id: x.id, name: label2 || "Echo", addedAt: typeof x.addedAt === "number" && Number.isFinite(x.addedAt) ? x.addedAt : 0 }];
2809
2691
  });
2692
+ 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;
2693
+ const locales = [...new Set((Array.isArray(r.locales) ? r.locales : []).filter((l) => ALEXA_LOCALES.includes(l)))];
2694
+ const tools = [...new Set((Array.isArray(r.extraVoiceTools) ? r.extraVoiceTools : []).filter((t) => typeof t === "string" && TOOL_NAME.test(t) && !isNeverByVoice(t)))].slice(0, 20);
2695
+ const maxDevices = num3(r.maxDevices, ALEXA_LIMITS.maxDevices);
2810
2696
  return {
2811
2697
  installed: r.installed === true || r.installed === void 0 && (r.enabled === true || skillId !== ""),
2812
2698
  enabled: r.enabled === true,
2813
2699
  skillId,
2814
2700
  invocationName: name,
2815
- allowedUsers: users.slice(0, 10)
2701
+ skillName: (typeof r.skillName === "string" ? r.skillName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 50) : "") || "Friend Assistant",
2702
+ locales: locales.length ? locales : [...ALEXA_LOCALES],
2703
+ answerSeconds: num3(r.answerSeconds, ALEXA_LIMITS.answerSeconds, false),
2704
+ holdMinutes: num3(r.holdMinutes, ALEXA_LIMITS.holdMinutes),
2705
+ permissionSeconds: num3(r.permissionSeconds, ALEXA_LIMITS.permissionSeconds),
2706
+ requestsPerMinute: num3(r.requestsPerMinute, ALEXA_LIMITS.requestsPerMinute),
2707
+ maxDevices,
2708
+ extraVoiceTools: tools,
2709
+ allowedUsers: users.slice(0, maxDevices)
2816
2710
  };
2817
2711
  }
2818
2712
  var DEFAULT_TIMEZONE = "Asia/Kolkata";
2713
+ var validNumberLocale = (l) => {
2714
+ if (typeof l !== "string" || !l || l.length > 35) return false;
2715
+ try {
2716
+ return Intl.NumberFormat.supportedLocalesOf([l]).length === 1;
2717
+ } catch {
2718
+ return false;
2719
+ }
2720
+ };
2819
2721
  var isValidTimeZone = (z12) => {
2820
2722
  if (typeof z12 !== "string" || !z12) return false;
2821
2723
  try {
@@ -2835,7 +2737,16 @@ function sanitizeBrokers(raw) {
2835
2737
  const z12 = r.zerodha && typeof r.zerodha === "object" ? r.zerodha : {};
2836
2738
  const str2 = (v, max) => typeof v === "string" ? v.trim().slice(0, max) : "";
2837
2739
  const reminder = typeof r.loginReminderMinutes === "number" && Number.isFinite(r.loginReminderMinutes) ? Math.min(120, Math.max(0, Math.round(r.loginReminderMinutes))) : 15;
2740
+ const num3 = (v, min, max, def) => typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def;
2838
2741
  return {
2742
+ currencySymbol: str2(r.currencySymbol, 4) || "\u20B9",
2743
+ currencyName: str2(r.currencyName, 20) || "rupees",
2744
+ numberLocale: validNumberLocale(r.numberLocale) ? r.numberLocale : "en-IN",
2745
+ concentrationPct: num3(r.concentrationPct, 5, 90, 30),
2746
+ bigDropPct: num3(r.bigDropPct, 1, 50, 5),
2747
+ cacheSeconds: num3(r.cacheSeconds, 5, 300, 30),
2748
+ maxSchedules: num3(r.maxSchedules, 1, 50, 20),
2749
+ maxTriesPerDay: num3(r.maxTriesPerDay, 1, 10, 3),
2839
2750
  zerodha: {
2840
2751
  installed: z12.installed === true || z12.installed === void 0 && (z12.enabled === true || !!str2(z12.apiKey, 64)),
2841
2752
  enabled: z12.enabled === true,
@@ -3736,8 +3647,8 @@ async function stopLive(l) {
3736
3647
  if (pid) killTree(pid);
3737
3648
  }
3738
3649
  var messageOf = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 400);
3739
- var sleepReject = (ms, what) => new Promise((_, rej) => {
3740
- const t = setTimeout(() => rej(new Error(what)), ms);
3650
+ var sleepReject = (ms2, what) => new Promise((_, rej) => {
3651
+ const t = setTimeout(() => rej(new Error(what)), ms2);
3741
3652
  t.unref?.();
3742
3653
  });
3743
3654
  var McpHub = class {
@@ -4722,7 +4633,7 @@ var ChannelHub = class _ChannelHub {
4722
4633
  this.deps = deps;
4723
4634
  this.f = deps.fetch ?? fetch;
4724
4635
  this.now = deps.clock ?? Date.now;
4725
- this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4636
+ this.sleep = deps.sleep ?? ((ms2) => new Promise((r) => setTimeout(r, ms2)));
4726
4637
  }
4727
4638
  deps;
4728
4639
  f;
@@ -5021,7 +4932,6 @@ var ChannelHub = class _ChannelHub {
5021
4932
 
5022
4933
  // ../server/src/alexa/devices.ts
5023
4934
  var MAX_WAITING = 20;
5024
- var MAX_ALLOWED = 10;
5025
4935
  var WAITING_TTL_MS = 24 * 36e5;
5026
4936
  var USER_ID = /^amzn1\.ask\.account\.[A-Za-z0-9._-]{8,500}$/;
5027
4937
  var SHORT = 8;
@@ -5056,7 +4966,7 @@ var AlexaDevices = class {
5056
4966
  allow(short, name) {
5057
4967
  this.prune();
5058
4968
  const matches = [...this.waitingMap.values()].filter((w2) => short.length === SHORT && w2.id.endsWith(short));
5059
- if (matches.length !== 1 || this.store.config().allowedUsers.length >= MAX_ALLOWED) return false;
4969
+ if (matches.length !== 1 || this.store.config().allowedUsers.length >= this.store.config().maxDevices) return false;
5060
4970
  const w = matches[0];
5061
4971
  const label2 = name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) || "Echo";
5062
4972
  this.store.update((a) => a.allowedUsers.push({ id: w.id, name: label2, addedAt: this.clock() }));
@@ -5079,15 +4989,43 @@ var AlexaDevices = class {
5079
4989
 
5080
4990
  // ../server/src/alexa/phrases.ts
5081
4991
  var langOf = (locale2) => locale2?.toLowerCase().startsWith("hi") ? "hi" : "en";
5082
- var LEAD_INTENTS = ["HowIntent", "WhatIntent", "TellMeIntent", "ShowMeIntent", "RemindMeIntent", "PleaseIntent"];
5083
- var LEAD_INS = {
5084
- HowIntent: { en: "how", hi: "\u0915\u0948\u0938\u0947" },
5085
- WhatIntent: { en: "what", hi: "\u0915\u094D\u092F\u093E" },
5086
- TellMeIntent: { en: "tell me", hi: "\u092E\u0941\u091D\u0947 \u092C\u0924\u093E\u0913" },
5087
- ShowMeIntent: { en: "show me", hi: "\u092E\u0941\u091D\u0947 \u0926\u093F\u0916\u093E\u0913" },
5088
- RemindMeIntent: { en: "remind me", hi: "\u092E\u0941\u091D\u0947 \u092F\u093E\u0926 \u0926\u093F\u0932\u093E\u0913" },
5089
- PleaseIntent: { en: "please", hi: "\u0915\u0943\u092A\u092F\u093E" }
5090
- };
4992
+ var LEADS = [
4993
+ ["HowIntent", "how", "\u0915\u0948\u0938\u0947"],
4994
+ ["WhatIntent", "what", "\u0915\u094D\u092F\u093E"],
4995
+ ["TellMeIntent", "tell me", "\u092E\u0941\u091D\u0947 \u092C\u0924\u093E\u0913"],
4996
+ ["ShowMeIntent", "show me", "\u092E\u0941\u091D\u0947 \u0926\u093F\u0916\u093E\u0913"],
4997
+ ["RemindMeIntent", "remind me", "\u092E\u0941\u091D\u0947 \u092F\u093E\u0926 \u0926\u093F\u0932\u093E\u0913"],
4998
+ ["PleaseIntent", "please", "\u0915\u0943\u092A\u092F\u093E"],
4999
+ ["CanYouIntent", "can you", "\u0915\u094D\u092F\u093E \u0924\u0941\u092E"],
5000
+ ["CouldYouIntent", "could you", "\u0915\u094D\u092F\u093E \u0906\u092A"],
5001
+ ["IsThereIntent", "is there", "\u0915\u094D\u092F\u093E \u0915\u094B\u0908"],
5002
+ ["GiveMeIntent", "give me", "\u092E\u0941\u091D\u0947 \u0926\u094B"],
5003
+ ["FindIntent", "find", "\u0916\u094B\u091C\u094B"],
5004
+ ["SearchIntent", "search for", "\u0922\u0942\u0901\u0922\u094B"],
5005
+ ["CheckIntent", "check", "\u091C\u093E\u0901\u091A\u094B"],
5006
+ ["AddIntent", "add", "\u091C\u094B\u0921\u093C\u094B"],
5007
+ ["SetIntent", "set", "\u0938\u0947\u091F \u0915\u0930\u094B"],
5008
+ ["CreateIntent", "create", "\u092C\u0928\u093E\u0913"],
5009
+ ["WriteIntent", "write", "\u0932\u093F\u0916\u094B"],
5010
+ ["ReadIntent", "read", "\u092A\u0922\u093C\u094B"],
5011
+ ["SendIntent", "send", "\u092D\u0947\u091C\u094B"],
5012
+ ["WhenIntent", "when", "\u0915\u092C"],
5013
+ ["WhereIntent", "where", "\u0915\u0939\u093E\u0901"],
5014
+ ["WhoIntent", "who", "\u0915\u094C\u0928"],
5015
+ ["WhyIntent", "why", "\u0915\u094D\u092F\u094B\u0902"],
5016
+ ["WhichIntent", "which", "\u0915\u094C\u0928 \u0938\u093E"],
5017
+ ["ExplainIntent", "explain", "\u0938\u092E\u091D\u093E\u0913"],
5018
+ ["SummarizeIntent", "summarize", "\u0938\u093E\u0930\u093E\u0902\u0936 \u0926\u094B"],
5019
+ ["CalculateIntent", "calculate", "\u0917\u0923\u0928\u093E \u0915\u0930\u094B"],
5020
+ ["ConvertIntent", "convert", "\u092C\u0926\u0932\u094B"],
5021
+ ["TranslateIntent", "translate", "\u0905\u0928\u0941\u0935\u093E\u0926 \u0915\u0930\u094B"],
5022
+ ["ListIntent", "list", "\u0938\u0942\u091A\u0940 \u0926\u093F\u0916\u093E\u0913"],
5023
+ ["DoIntent", "do", "\u0915\u094D\u092F\u093E \u092E\u0948\u0902"],
5024
+ ["IsIntent", "is", "\u0915\u094D\u092F\u093E \u092F\u0939"],
5025
+ ["AreIntent", "are", "\u0915\u094D\u092F\u093E \u092F\u0947"]
5026
+ ];
5027
+ var LEAD_INTENTS = LEADS.map((l) => l[0]);
5028
+ var LEAD_INS = Object.fromEntries(LEADS.map(([name, en, hi]) => [name, { en, hi }]));
5091
5029
  var SAY = {
5092
5030
  notAllowed: { en: "I do not know this Echo yet. Allow it in Friend, under Plugins, Alexa.", hi: "\u092E\u0948\u0902 \u0907\u0938 \u0907\u0915\u094B \u0915\u094B \u0905\u092D\u0940 \u0928\u0939\u0940\u0902 \u092A\u0939\u091A\u093E\u0928\u0924\u093E\u0964 \u092B\u094D\u0930\u0947\u0902\u0921 \u092E\u0947\u0902 \u092A\u094D\u0932\u0917\u0907\u0928\u094D\u0938, \u0905\u0932\u0947\u0915\u094D\u0938\u093E \u092E\u0947\u0902 \u0907\u0938\u0947 \u0905\u0928\u0941\u092E\u0924\u093F \u0926\u0940\u091C\u093F\u090F\u0964" },
5093
5031
  greet: { en: "Friend here. Ask me about your portfolio, the weather, your tasks, or anything else.", hi: "\u092B\u094D\u0930\u0947\u0902\u0921 \u092F\u0939\u093E\u0901 \u0939\u0948\u0964 \u092E\u0941\u091D\u0938\u0947 \u0905\u092A\u0928\u0947 \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B, \u092E\u094C\u0938\u092E, \u0915\u093E\u092E\u094B\u0902 \u092F\u093E \u0915\u0941\u091B \u092D\u0940 \u092A\u0942\u091B\u093F\u090F\u0964" },
@@ -5131,6 +5069,7 @@ function shortForVoice(text2) {
5131
5069
 
5132
5070
  // ../server/src/alexa/engine.ts
5133
5071
  var FAST = ["PortfolioIntent", "WeatherIntent", "TasksIntent", "StatusIntent"];
5072
+ var ms = (v, def) => typeof v === "function" ? v() : v ?? def;
5134
5073
  var AlexaEngine = class {
5135
5074
  constructor(d) {
5136
5075
  this.d = d;
@@ -5178,7 +5117,7 @@ var AlexaEngine = class {
5178
5117
  ready(key, lang) {
5179
5118
  const slot = this.slots.get(key);
5180
5119
  if (slot?.pending) return { speech: say(lang, "working"), end: false };
5181
- if (!slot || this.d.clock() - slot.at > (this.d.holdMs ?? 6e5)) {
5120
+ if (!slot || this.d.clock() - slot.at > ms(this.d.holdMs, 6e5)) {
5182
5121
  this.slots.delete(key);
5183
5122
  return { speech: say(lang, "nothing"), end: true };
5184
5123
  }
@@ -5203,7 +5142,7 @@ var AlexaEngine = class {
5203
5142
  const waited = await Promise.race([
5204
5143
  work,
5205
5144
  new Promise((resolve11) => {
5206
- timer = setTimeout(() => resolve11("late"), this.d.budgetMs ?? 6500);
5145
+ timer = setTimeout(() => resolve11("late"), ms(this.d.budgetMs, 6500));
5207
5146
  })
5208
5147
  ]);
5209
5148
  clearTimeout(timer);
@@ -5222,6 +5161,70 @@ var AlexaEngine = class {
5222
5161
  }
5223
5162
  };
5224
5163
 
5164
+ // ../server/src/alexa/package.ts
5165
+ var packageFileNames = (locales) => ["skill.json", ...locales.map((l) => `${l}.json`)];
5166
+ var langOf2 = (locale2) => locale2.startsWith("hi") ? "hi" : "en";
5167
+ var FIXED = {
5168
+ en: {
5169
+ PortfolioIntent: ["how is my portfolio", "how is my portfolio doing", "how are my stocks doing", "what is my portfolio", "my portfolio", "portfolio"],
5170
+ TasksIntent: ["what are my tasks", "what do i have to do", "what is on my list", "read my tasks", "my tasks"],
5171
+ StatusIntent: ["status", "give me the status", "what is the status", "are you there"],
5172
+ ReadyIntent: ["is it ready", "is it done", "are you done", "do you have an answer", "what is the answer"],
5173
+ weather: ["what is the weather in {city}", "how is the weather in {city}", "weather in {city}", "tell me the weather in {city}"]
5174
+ },
5175
+ hi: {
5176
+ PortfolioIntent: ["\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u0939\u0948", "\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u091A\u0932 \u0930\u0939\u093E \u0939\u0948", "\u092E\u0947\u0930\u0947 \u0936\u0947\u092F\u0930 \u0915\u0948\u0938\u0947 \u0939\u0948\u0902", "\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B", "\u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B"],
5177
+ TasksIntent: ["\u092E\u0947\u0930\u0947 \u0915\u093E\u092E \u0915\u094D\u092F\u093E \u0939\u0948\u0902", "\u092E\u0941\u091D\u0947 \u0915\u094D\u092F\u093E \u0915\u0930\u0928\u093E \u0939\u0948", "\u092E\u0947\u0930\u0940 \u0938\u0942\u091A\u0940 \u092E\u0947\u0902 \u0915\u094D\u092F\u093E \u0939\u0948", "\u092E\u0947\u0930\u0947 \u0915\u093E\u092E \u092A\u0922\u093C\u094B", "\u092E\u0947\u0930\u0947 \u0915\u093E\u092E"],
5178
+ StatusIntent: ["\u0938\u094D\u0925\u093F\u0924\u093F", "\u0938\u094D\u0925\u093F\u0924\u093F \u092C\u0924\u093E\u0913", "\u0915\u094D\u092F\u093E \u0924\u0941\u092E \u092F\u0939\u093E\u0901 \u0939\u094B"],
5179
+ ReadyIntent: ["\u0915\u094D\u092F\u093E \u0924\u0948\u092F\u093E\u0930 \u0939\u0948", "\u091C\u0935\u093E\u092C \u0924\u0948\u092F\u093E\u0930 \u0939\u0948", "\u0939\u094B \u0917\u092F\u093E \u0915\u094D\u092F\u093E", "\u0915\u094D\u092F\u093E \u091C\u0935\u093E\u092C \u0939\u0948"],
5180
+ weather: ["{city} \u092E\u0947\u0902 \u092E\u094C\u0938\u092E \u0915\u0948\u0938\u093E \u0939\u0948", "{city} \u0915\u093E \u092E\u094C\u0938\u092E \u0915\u0948\u0938\u093E \u0939\u0948", "{city} \u0915\u093E \u092E\u094C\u0938\u092E"]
5181
+ }
5182
+ };
5183
+ var BUILT_IN = ["AMAZON.CancelIntent", "AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.FallbackIntent", "AMAZON.NavigateHomeIntent"];
5184
+ function intents(lang) {
5185
+ const f = FIXED[lang];
5186
+ return [
5187
+ ...BUILT_IN.map((name) => ({ name, samples: [] })),
5188
+ { name: "PortfolioIntent", samples: f.PortfolioIntent },
5189
+ { name: "WeatherIntent", slots: [{ name: "city", type: "AMAZON.SearchQuery" }], samples: f.weather },
5190
+ { name: "TasksIntent", samples: f.TasksIntent },
5191
+ { name: "StatusIntent", samples: f.StatusIntent },
5192
+ { name: "ReadyIntent", samples: f.ReadyIntent },
5193
+ // open questions: one free-text slot per sentence, so each lead-in is its own intent and the engine can put the sentence back together
5194
+ ...LEAD_INTENTS.map((name) => ({ name, slots: [{ name: "q", type: "AMAZON.SearchQuery" }], samples: [`${LEAD_INS[name][lang]} {q}`] }))
5195
+ ];
5196
+ }
5197
+ var model = (invocationName, lang) => JSON.stringify({ interactionModel: { languageModel: { invocationName, intents: intents(lang), types: [] } } }, null, 2);
5198
+ var locale = (lang, invocationName, name) => ({
5199
+ name,
5200
+ summary: lang === "hi" ? "\u0905\u092A\u0928\u0947 \u092B\u094D\u0930\u0947\u0902\u0921 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F \u0938\u0947 \u092C\u093E\u0924 \u0915\u0930\u0947\u0902\u0964" : "Talk to your own Friend assistant.",
5201
+ description: lang === "hi" ? "\u092F\u0939 \u0906\u092A\u0915\u0947 \u0905\u092A\u0928\u0947 \u092B\u094D\u0930\u0947\u0902\u0921 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F \u0915\u0947 \u0932\u093F\u090F \u0928\u093F\u091C\u0940 \u0938\u094D\u0915\u093F\u0932 \u0939\u0948\u0964 \u0906\u092A\u0915\u0947 \u0905\u092A\u0928\u0947 \u0915\u0902\u092A\u094D\u092F\u0942\u091F\u0930 \u092A\u0930 \u091A\u0932\u0924\u093E \u0939\u0948\u0964" : "A private skill for your own self-hosted Friend assistant. It talks to your own computer.",
5202
+ examplePhrases: [lang === "hi" ? `\u0905\u0932\u0947\u0915\u094D\u0938\u093E, ${invocationName} \u0938\u0947 \u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u0939\u0948 \u092A\u0942\u091B\u094B` : `Alexa, ask ${invocationName} how my portfolio is`, `Alexa, open ${invocationName}`, `Alexa, ask ${invocationName} what is on my list`],
5203
+ keywords: ["friend", "assistant"]
5204
+ });
5205
+ function buildSkillPackage(o) {
5206
+ const locales = o.locales?.length ? o.locales : ALEXA_LOCALES;
5207
+ const skillName = o.skillName?.trim() || "Friend Assistant";
5208
+ const problem = invocationNameProblem(o.invocationName);
5209
+ if (problem) throw new Error(problem);
5210
+ if (!/^https:\/\/[^\s/]+\/\S*$/.test(o.endpoint)) throw new Error("The address must be an https address, such as https://your-address/api/alexa.");
5211
+ const manifest = {
5212
+ manifest: {
5213
+ manifestVersion: "1.0",
5214
+ publishingInformation: {
5215
+ locales: Object.fromEntries(locales.map((l) => [l, locale(langOf2(l), o.invocationName, skillName)])),
5216
+ isAvailableWorldwide: false,
5217
+ distributionCountries: [...new Set(locales.map((l) => l.slice(3)))],
5218
+ category: "ORGANIZERS_AND_ASSISTANTS",
5219
+ testingInstructions: "A private skill for its owner's own self-hosted assistant. It is used in development mode only."
5220
+ },
5221
+ apis: { custom: { endpoint: { uri: o.endpoint, sslCertificateType: "Trusted" } } },
5222
+ privacyAndCompliance: { allowsPurchases: false, usesPersonalInfo: false, isChildDirected: false, isExportCompliant: true, containsAds: false }
5223
+ }
5224
+ };
5225
+ return { "skill.json": JSON.stringify(manifest, null, 2), ...Object.fromEntries(locales.map((l) => [`${l}.json`, model(o.invocationName, langOf2(l))])) };
5226
+ }
5227
+
5225
5228
  // ../server/src/alexa/permission.ts
5226
5229
  var YES = /* @__PURE__ */ new Set(["yes", "y", "yeah", "ok", "okay", "ha", "haan", "han", "\u0939\u093E\u0901", "\u0939\u093E\u0902", "\u0939\u093E"]);
5227
5230
  var NO = /* @__PURE__ */ new Set(["no", "n", "nope", "nahi", "nahin", "\u0928\u0939\u0940\u0902", "\u0928\u093E"]);
@@ -5242,17 +5245,19 @@ var VoicePermission = class {
5242
5245
  const channels = this.d.linked();
5243
5246
  const screen = this.d.screenOpen();
5244
5247
  if (card.needsPin || !channels.length && !screen) return answer(card.id, false);
5248
+ const waitMs = typeof this.d.waitMs === "function" ? this.d.waitMs() : this.d.waitMs ?? 12e4;
5249
+ const minutes2 = Math.max(1, Math.round(waitMs / 6e4));
5245
5250
  const entry = {
5246
5251
  id: card.id,
5247
5252
  answer,
5248
- timer: setTimeout(() => this.settle(card.id, false), this.d.waitMs ?? 12e4)
5253
+ timer: setTimeout(() => this.settle(card.id, false), waitMs)
5249
5254
  };
5250
5255
  this.waiting.push(entry);
5251
5256
  if (screen) base(card);
5252
5257
  if (channels.length) {
5253
5258
  const channel = channels.includes("telegram") ? "telegram" : channels[0];
5254
5259
  const text2 = `Alexa asked to: ${card.summary}
5255
- Reply YES to allow it, or NO to refuse, within 2 minutes.`;
5260
+ Reply YES to allow it, or NO to refuse, within ${minutes2} ${minutes2 === 1 ? "minute" : "minutes"}.`;
5256
5261
  this.d.send(channel, "me", text2).then(
5257
5262
  (r) => {
5258
5263
  if (!r.ok && !screen) this.settle(card.id, false);
@@ -5409,6 +5414,7 @@ var AlexaHub = class {
5409
5414
  this.d = d;
5410
5415
  this.devices = new AlexaDevices(d.store, d.clock);
5411
5416
  this.permission = new VoicePermission({
5417
+ waitMs: () => d.config.alexa.permissionSeconds * 1e3,
5412
5418
  send: async (channel, to, text2) => {
5413
5419
  const r = await d.channels.send(channel, to, text2);
5414
5420
  return { ok: r.ok };
@@ -5416,7 +5422,13 @@ var AlexaHub = class {
5416
5422
  linked: d.linked,
5417
5423
  screenOpen: d.screenOpen
5418
5424
  });
5419
- this.engine = new AlexaEngine({ ask: (t, l, k) => this.ask(t, l, k), fast: d.fast, clock: d.clock });
5425
+ this.engine = new AlexaEngine({
5426
+ ask: (t, l, k) => this.ask(t, l, k),
5427
+ fast: d.fast,
5428
+ clock: d.clock,
5429
+ budgetMs: () => d.config.alexa.answerSeconds * 1e3,
5430
+ holdMs: () => d.config.alexa.holdMinutes * 6e4
5431
+ });
5420
5432
  }
5421
5433
  d;
5422
5434
  devices;
@@ -5428,7 +5440,7 @@ var AlexaHub = class {
5428
5440
  }
5429
5441
  status() {
5430
5442
  const a = this.d.config.alexa;
5431
- return { installed: a.installed, enabled: a.enabled, skillIdSet: !!a.skillId, invocationName: a.invocationName, endpoint: this.endpoint(), waiting: this.devices.waiting(), allowed: this.devices.allowed() };
5443
+ return { installed: a.installed, enabled: a.enabled, skillIdSet: !!a.skillId, invocationName: a.invocationName, endpoint: this.endpoint(), files: packageFileNames(a.locales), waiting: this.devices.waiting(), allowed: this.devices.allowed() };
5432
5444
  }
5433
5445
  /** The owner's reply on Telegram or WhatsApp. True when it answered a permission request from Alexa. */
5434
5446
  tryApprove(text2) {
@@ -5500,83 +5512,21 @@ var AlexaHub = class {
5500
5512
  }
5501
5513
  };
5502
5514
 
5503
- // ../server/src/alexa/package.ts
5504
- var PACKAGE_FILES = ["skill.json", "en-IN.json", "hi-IN.json"];
5505
- var FIXED = {
5506
- en: {
5507
- PortfolioIntent: ["how is my portfolio", "how is my portfolio doing", "how are my stocks doing", "what is my portfolio", "my portfolio", "portfolio"],
5508
- TasksIntent: ["what are my tasks", "what do i have to do", "what is on my list", "read my tasks", "my tasks"],
5509
- StatusIntent: ["status", "give me the status", "what is the status", "are you there"],
5510
- ReadyIntent: ["is it ready", "is it done", "are you done", "do you have an answer", "what is the answer"],
5511
- weather: ["what is the weather in {city}", "how is the weather in {city}", "weather in {city}", "tell me the weather in {city}"]
5512
- },
5513
- hi: {
5514
- PortfolioIntent: ["\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u0939\u0948", "\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u091A\u0932 \u0930\u0939\u093E \u0939\u0948", "\u092E\u0947\u0930\u0947 \u0936\u0947\u092F\u0930 \u0915\u0948\u0938\u0947 \u0939\u0948\u0902", "\u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B", "\u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B"],
5515
- TasksIntent: ["\u092E\u0947\u0930\u0947 \u0915\u093E\u092E \u0915\u094D\u092F\u093E \u0939\u0948\u0902", "\u092E\u0941\u091D\u0947 \u0915\u094D\u092F\u093E \u0915\u0930\u0928\u093E \u0939\u0948", "\u092E\u0947\u0930\u0940 \u0938\u0942\u091A\u0940 \u092E\u0947\u0902 \u0915\u094D\u092F\u093E \u0939\u0948", "\u092E\u0947\u0930\u0947 \u0915\u093E\u092E \u092A\u0922\u093C\u094B", "\u092E\u0947\u0930\u0947 \u0915\u093E\u092E"],
5516
- StatusIntent: ["\u0938\u094D\u0925\u093F\u0924\u093F", "\u0938\u094D\u0925\u093F\u0924\u093F \u092C\u0924\u093E\u0913", "\u0915\u094D\u092F\u093E \u0924\u0941\u092E \u092F\u0939\u093E\u0901 \u0939\u094B"],
5517
- ReadyIntent: ["\u0915\u094D\u092F\u093E \u0924\u0948\u092F\u093E\u0930 \u0939\u0948", "\u091C\u0935\u093E\u092C \u0924\u0948\u092F\u093E\u0930 \u0939\u0948", "\u0939\u094B \u0917\u092F\u093E \u0915\u094D\u092F\u093E", "\u0915\u094D\u092F\u093E \u091C\u0935\u093E\u092C \u0939\u0948"],
5518
- weather: ["{city} \u092E\u0947\u0902 \u092E\u094C\u0938\u092E \u0915\u0948\u0938\u093E \u0939\u0948", "{city} \u0915\u093E \u092E\u094C\u0938\u092E \u0915\u0948\u0938\u093E \u0939\u0948", "{city} \u0915\u093E \u092E\u094C\u0938\u092E"]
5519
- }
5520
- };
5521
- var BUILT_IN = ["AMAZON.CancelIntent", "AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.FallbackIntent", "AMAZON.NavigateHomeIntent"];
5522
- function intents(lang) {
5523
- const f = FIXED[lang];
5524
- return [
5525
- ...BUILT_IN.map((name) => ({ name, samples: [] })),
5526
- { name: "PortfolioIntent", samples: f.PortfolioIntent },
5527
- { name: "WeatherIntent", slots: [{ name: "city", type: "AMAZON.SearchQuery" }], samples: f.weather },
5528
- { name: "TasksIntent", samples: f.TasksIntent },
5529
- { name: "StatusIntent", samples: f.StatusIntent },
5530
- { name: "ReadyIntent", samples: f.ReadyIntent },
5531
- // open questions: one free-text slot per sentence, so each lead-in is its own intent and the engine can put the sentence back together
5532
- ...LEAD_INTENTS.map((name) => ({ name, slots: [{ name: "q", type: "AMAZON.SearchQuery" }], samples: [`${LEAD_INS[name][lang]} {q}`] }))
5533
- ];
5534
- }
5535
- var model = (invocationName, lang) => JSON.stringify({ interactionModel: { languageModel: { invocationName, intents: intents(lang), types: [] } } }, null, 2);
5536
- var SKILL_NAME = "Friend Assistant";
5537
- var locale = (lang, invocationName) => ({
5538
- name: SKILL_NAME,
5539
- summary: lang === "hi" ? "\u0905\u092A\u0928\u0947 \u092B\u094D\u0930\u0947\u0902\u0921 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F \u0938\u0947 \u092C\u093E\u0924 \u0915\u0930\u0947\u0902\u0964" : "Talk to your own Friend assistant.",
5540
- description: lang === "hi" ? "\u092F\u0939 \u0906\u092A\u0915\u0947 \u0905\u092A\u0928\u0947 \u092B\u094D\u0930\u0947\u0902\u0921 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F \u0915\u0947 \u0932\u093F\u090F \u0928\u093F\u091C\u0940 \u0938\u094D\u0915\u093F\u0932 \u0939\u0948\u0964 \u0906\u092A\u0915\u0947 \u0905\u092A\u0928\u0947 \u0915\u0902\u092A\u094D\u092F\u0942\u091F\u0930 \u092A\u0930 \u091A\u0932\u0924\u093E \u0939\u0948\u0964" : "A private skill for your own self-hosted Friend assistant. It talks to your own computer.",
5541
- examplePhrases: [lang === "hi" ? `\u0905\u0932\u0947\u0915\u094D\u0938\u093E, ${invocationName} \u0938\u0947 \u092E\u0947\u0930\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B \u0915\u0948\u0938\u093E \u0939\u0948 \u092A\u0942\u091B\u094B` : `Alexa, ask ${invocationName} how my portfolio is`, `Alexa, open ${invocationName}`, `Alexa, ask ${invocationName} what is on my list`],
5542
- keywords: ["friend", "assistant"]
5543
- });
5544
- function buildSkillPackage(o) {
5545
- const problem = invocationNameProblem(o.invocationName);
5546
- if (problem) throw new Error(problem);
5547
- if (!/^https:\/\/[^\s/]+\/\S*$/.test(o.endpoint)) throw new Error("The address must be an https address, such as https://your-address/api/alexa.");
5548
- const manifest = {
5549
- manifest: {
5550
- manifestVersion: "1.0",
5551
- publishingInformation: {
5552
- locales: { "en-IN": locale("en", o.invocationName), "hi-IN": locale("hi", o.invocationName) },
5553
- isAvailableWorldwide: false,
5554
- distributionCountries: ["IN"],
5555
- category: "ORGANIZERS_AND_ASSISTANTS",
5556
- testingInstructions: "A private skill for its owner's own self-hosted assistant. It is used in development mode only."
5557
- },
5558
- apis: { custom: { endpoint: { uri: o.endpoint, sslCertificateType: "Trusted" } } },
5559
- privacyAndCompliance: { allowsPurchases: false, usesPersonalInfo: false, isChildDirected: false, isExportCompliant: true, containsAds: false }
5560
- }
5561
- };
5562
- return {
5563
- "skill.json": JSON.stringify(manifest, null, 2),
5564
- "en-IN.json": model(o.invocationName, "en"),
5565
- "hi-IN.json": model(o.invocationName, "hi")
5566
- };
5567
- }
5568
-
5569
5515
  // ../server/src/alexa/fast.ts
5570
- var rupees = (n, lang) => `${formatInr(Math.abs(n)).replace("\u20B9", "").replace(/\.00$/, "")} ${lang === "hi" ? "\u0930\u0941\u092A\u092F\u0947" : "rupees"}`;
5516
+ var rupees = (n, lang, o = {}) => {
5517
+ const word2 = o.currencyName ?? "rupees";
5518
+ const spoken = lang === "hi" && word2 === "rupees" ? "\u0930\u0941\u092A\u092F\u0947" : word2;
5519
+ return `${formatMoney(Math.abs(n), { symbol: "", locale: (o.money ?? DEFAULT_MONEY).locale }).replace(/[.,]00$/, "")} ${spoken}`;
5520
+ };
5571
5521
  var pct = (n) => String(Math.round(Math.abs(n) * 100) / 100);
5572
- function portfolioSpeech(s, lang) {
5522
+ function portfolioSpeech(s, lang, o = {}) {
5573
5523
  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.";
5574
5524
  const up = s.dayChange >= 0;
5575
5525
  const gain = s.pnl >= 0;
5576
5526
  if (lang === "hi") {
5577
- return `\u0906\u092A\u0915\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B ${rupees(s.value, lang)} \u0915\u093E \u0939\u0948\u0964 \u0906\u091C \u092F\u0939 ${pct(s.dayChangePct)} \u092A\u094D\u0930\u0924\u093F\u0936\u0924 ${up ? "\u090A\u092A\u0930" : "\u0928\u0940\u091A\u0947"} \u0939\u0948\u0964 \u0915\u0941\u0932 ${gain ? "\u092E\u0941\u0928\u093E\u092B\u093C\u093E" : "\u0918\u093E\u091F\u093E"} ${rupees(s.pnl, lang)} \u0939\u0948\u0964`;
5527
+ return `\u0906\u092A\u0915\u093E \u092A\u094B\u0930\u094D\u091F\u092B\u094B\u0932\u093F\u092F\u094B ${rupees(s.value, lang, o)} \u0915\u093E \u0939\u0948\u0964 \u0906\u091C \u092F\u0939 ${pct(s.dayChangePct)} \u092A\u094D\u0930\u0924\u093F\u0936\u0924 ${up ? "\u090A\u092A\u0930" : "\u0928\u0940\u091A\u0947"} \u0939\u0948\u0964 \u0915\u0941\u0932 ${gain ? "\u092E\u0941\u0928\u093E\u092B\u093C\u093E" : "\u0918\u093E\u091F\u093E"} ${rupees(s.pnl, lang, o)} \u0939\u0948\u0964`;
5578
5528
  }
5579
- return `Your portfolio is worth ${rupees(s.value, lang)}. It is ${up ? "up" : "down"} ${pct(s.dayChangePct)} percent today, ${rupees(s.dayChange, lang)}. Overall you have a ${gain ? "profit" : "loss"} of ${rupees(s.pnl, lang)}.`;
5529
+ return `Your portfolio is worth ${rupees(s.value, lang, o)}. It is ${up ? "up" : "down"} ${pct(s.dayChangePct)} percent today, ${rupees(s.dayChange, lang, o)}. Overall you have a ${gain ? "profit" : "loss"} of ${rupees(s.pnl, lang, o)}.`;
5580
5530
  }
5581
5531
  var FIRST_TASKS = 3;
5582
5532
  function tasksSpeech(rows3, lang) {
@@ -5592,8 +5542,157 @@ function statusSpeech(s, lang) {
5592
5542
  return `Friend is running. You have ${s.openTasks} open ${s.openTasks === 1 ? "task" : "tasks"}.${s.brokerLoggedIn === false ? " Zerodha needs you to log in." : ""}`;
5593
5543
  }
5594
5544
 
5545
+ // ../server/src/brokers/types.ts
5546
+ var BrokerError = class extends Error {
5547
+ constructor(message, kind) {
5548
+ super(message);
5549
+ this.kind = kind;
5550
+ this.name = "BrokerError";
5551
+ }
5552
+ kind;
5553
+ };
5554
+
5555
+ // ../server/src/brokers/portfolio.ts
5556
+ var MOVERS = 3;
5557
+ var TOP = 5;
5558
+ var r2 = (n) => Number.isFinite(n) ? Math.round((n + Number.EPSILON) * 100) / 100 : 0;
5559
+ function summarize(holdings) {
5560
+ let value = 0;
5561
+ let invested = 0;
5562
+ let yesterday = 0;
5563
+ for (const h of holdings) {
5564
+ value += h.quantity * h.lastPrice;
5565
+ invested += h.quantity * h.avgPrice;
5566
+ yesterday += h.quantity * h.closePrice;
5567
+ }
5568
+ const dayChange = value - yesterday;
5569
+ const pnl = value - invested;
5570
+ const move = (h) => h.quantity * (h.lastPrice - h.closePrice);
5571
+ const worth = (h) => h.quantity * h.lastPrice;
5572
+ const top = [...holdings].sort((a, b) => worth(b) - worth(a)).slice(0, TOP).map((h) => ({ symbol: h.symbol, pct: value > 0 ? r2(worth(h) / value * 100) : 0 }));
5573
+ return {
5574
+ value: r2(value),
5575
+ invested: r2(invested),
5576
+ pnl: r2(pnl),
5577
+ pnlPct: invested > 0 ? r2(pnl / invested * 100) : 0,
5578
+ dayChange: r2(dayChange),
5579
+ dayChangePct: yesterday > 0 ? r2(dayChange / yesterday * 100) : 0,
5580
+ gainers: holdings.filter((h) => move(h) > 0).sort((a, b) => move(b) - move(a)).slice(0, MOVERS),
5581
+ losers: holdings.filter((h) => move(h) < 0).sort((a, b) => move(a) - move(b)).slice(0, MOVERS),
5582
+ topHoldingPct: top[0]?.pct ?? 0,
5583
+ top
5584
+ };
5585
+ }
5586
+ var Portfolio = class {
5587
+ constructor(provider, clock = Date.now, ttlMs = 3e4) {
5588
+ this.provider = provider;
5589
+ this.clock = clock;
5590
+ this.ttlMs = ttlMs;
5591
+ }
5592
+ provider;
5593
+ clock;
5594
+ ttlMs;
5595
+ cached = null;
5596
+ inflight = null;
5597
+ invalidate() {
5598
+ this.cached = null;
5599
+ }
5600
+ snapshot() {
5601
+ if (this.cached && this.clock() - this.cached.at < (typeof this.ttlMs === "function" ? this.ttlMs() : this.ttlMs)) return Promise.resolve(this.cached);
5602
+ this.inflight ??= this.read().finally(() => {
5603
+ this.inflight = null;
5604
+ });
5605
+ return this.inflight;
5606
+ }
5607
+ async read() {
5608
+ const at = this.clock();
5609
+ const caps = this.provider.capabilities;
5610
+ const [holdings, positions, funds, orders] = await Promise.all([
5611
+ section(caps.holdings, () => this.provider.holdings()),
5612
+ section(caps.positions, () => this.provider.positions()),
5613
+ section(caps.funds, () => this.provider.funds()),
5614
+ section(caps.orders, () => this.provider.orders())
5615
+ ]);
5616
+ this.cached = { at, holdings, positions, funds, orders };
5617
+ return this.cached;
5618
+ }
5619
+ };
5620
+ async function section(supported, read) {
5621
+ if (!supported) return { ok: false, error: "Not available from this broker.", kind: "other" };
5622
+ try {
5623
+ return { ok: true, data: await read() };
5624
+ } catch (e) {
5625
+ return e instanceof BrokerError ? { ok: false, error: e.message, kind: e.kind } : { ok: false, error: "Could not read this.", kind: "other" };
5626
+ }
5627
+ }
5628
+
5629
+ // ../server/src/brokers/report.ts
5630
+ var signedMoney = (n, m) => n > 4e-3 ? `+${formatMoney(n, m)}` : formatMoney(n, m);
5631
+ var signedPct = (p) => {
5632
+ const x = Number.isFinite(p) ? p : 0;
5633
+ const text2 = Math.abs(x).toFixed(2);
5634
+ return text2 === "0.00" ? "0.00%" : `${x > 0 ? "+" : "-"}${text2}%`;
5635
+ };
5636
+ var pct1 = (p) => Math.abs(p).toFixed(1);
5637
+ var TITLE = { morning: "Morning report", close: "Market close report", custom: "Portfolio report" };
5638
+ var WHY2 = { rate: "rate limit", session: "login expired", network: "network problem", auth: "login problem" };
5639
+ var missing = (label2, s) => `${label2}: not available (${WHY2[s.kind] ?? s.error.slice(0, 60)})`;
5640
+ var DEFAULT_REPORT_OPTIONS = { money: DEFAULT_MONEY, concentrationPct: 30, bigDropPct: 5 };
5641
+ function title(kind, broker, now, zone) {
5642
+ const parts = Object.fromEntries(
5643
+ new Intl.DateTimeFormat("en-GB", { timeZone: zone, weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now).map((p) => [p.type, p.value])
5644
+ );
5645
+ return `${TITLE[kind]} - ${broker} - ${parts.weekday} ${parts.day} ${parts.month}, ${parts.hour}:${parts.minute}`;
5646
+ }
5647
+ var dayMovePct = (h) => h.closePrice > 0 ? (h.lastPrice - h.closePrice) / h.closePrice * 100 : 0;
5648
+ function buildReport(input) {
5649
+ const { snapshot: snap, caps } = input;
5650
+ const o = input.options ?? DEFAULT_REPORT_OPTIONS;
5651
+ const fmt = (n) => formatMoney(n, o.money);
5652
+ const signed = (n) => signedMoney(n, o.money);
5653
+ const out = [title(input.kind, input.broker, input.now, input.zone ?? "Asia/Kolkata"), ""];
5654
+ if (caps.holdings) {
5655
+ if (!snap.holdings.ok) out.push(missing("Holdings", snap.holdings));
5656
+ else if (snap.holdings.data.length === 0) out.push("No holdings.");
5657
+ else {
5658
+ const holdings = snap.holdings.data;
5659
+ const s = summarize(holdings);
5660
+ out.push(`Portfolio: ${fmt(s.value)} (invested ${fmt(s.invested)})`);
5661
+ out.push(`Profit/loss: ${signed(s.pnl)} (${signedPct(s.pnlPct)})`);
5662
+ out.push(`Today: ${signed(s.dayChange)} (${signedPct(s.dayChangePct)})`);
5663
+ const mover = (h) => `- ${h.symbol} ${signed(h.quantity * (h.lastPrice - h.closePrice))} (${signedPct(dayMovePct(h))})`;
5664
+ if (s.gainers.length) out.push("", "Up today:", ...s.gainers.map(mover));
5665
+ if (s.losers.length) out.push("", "Down today:", ...s.losers.map(mover));
5666
+ const flags = [];
5667
+ const top = s.top[0];
5668
+ if (top && s.topHoldingPct > o.concentrationPct) flags.push(`- ${top.symbol} is ${pct1(s.topHoldingPct)}% of the portfolio.`);
5669
+ for (const h of holdings) if (dayMovePct(h) < -o.bigDropPct) flags.push(`- ${h.symbol} is down ${pct1(dayMovePct(h))}% today.`);
5670
+ if (flags.length) out.push("", "Worth a look:", ...flags);
5671
+ }
5672
+ }
5673
+ if (caps.positions) {
5674
+ out.push("");
5675
+ if (!snap.positions.ok) out.push(missing("Positions", snap.positions));
5676
+ else {
5677
+ const open = snap.positions.data.filter((p) => p.quantity !== 0);
5678
+ if (open.length === 0) out.push("No open positions.");
5679
+ else {
5680
+ out.push("Open positions:");
5681
+ for (const p of open) out.push(`- ${p.symbol} ${p.product} ${p.quantity}${p.quantity < 0 ? " (short)" : ""} @ ${fmt(p.avgPrice)}, P&L ${signed(p.pnl)}`);
5682
+ }
5683
+ }
5684
+ }
5685
+ if (caps.funds) {
5686
+ out.push("");
5687
+ if (!snap.funds.ok) out.push(missing("Funds", snap.funds));
5688
+ else out.push(`Funds: ${fmt(snap.funds.data.available)} available, ${fmt(snap.funds.data.used)} used`);
5689
+ }
5690
+ if (caps.orders && snap.orders.ok && snap.orders.data.length > 0) out.push(`Orders today: ${snap.orders.data.length}`);
5691
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
5692
+ }
5693
+
5595
5694
  // ../server/src/brokers/scheduler.ts
5596
- var MAX_TRIES_PER_DAY = 3;
5695
+ var DEFAULT_TRIES_PER_DAY = 3;
5597
5696
  var WEEKDAYS = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
5598
5697
  function localDay(now, zone) {
5599
5698
  return new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(now);
@@ -5619,7 +5718,7 @@ var ReportScheduler = class {
5619
5718
  let sent = 0;
5620
5719
  for (const s of this.d.schedules.list()) {
5621
5720
  if (!s.enabled || s.lastRunDay === day || !s.days.includes(weekday) || time < s.time) continue;
5622
- if (this.d.schedules.noteAttempt(s.id, day) > MAX_TRIES_PER_DAY) continue;
5721
+ if (this.d.schedules.noteAttempt(s.id, day) > (this.d.maxTries?.() ?? DEFAULT_TRIES_PER_DAY)) continue;
5623
5722
  try {
5624
5723
  await this.d.run(s);
5625
5724
  this.d.schedules.markRun(s.id, day);
@@ -5649,10 +5748,13 @@ var toSchedule = (r) => ({
5649
5748
  lastRunDay: r.last_run_day
5650
5749
  });
5651
5750
  var ReportSchedules = class {
5652
- constructor(db) {
5751
+ /** `max`: how many schedules there may be (the owner's setting). */
5752
+ constructor(db, max = () => MAX_SCHEDULES) {
5653
5753
  this.db = db;
5754
+ this.max = max;
5654
5755
  }
5655
5756
  db;
5757
+ max;
5656
5758
  list() {
5657
5759
  return this.db.prepare("select id, name, broker, kind, time, days, channel, commentary, enabled, last_run_day from report_schedules order by id").all().map(toSchedule);
5658
5760
  }
@@ -5665,7 +5767,8 @@ var ReportSchedules = class {
5665
5767
  if (days.length === 0 || days.length !== new Set(s.days).size) throw new Error("Choose the days as numbers 1 (Monday) to 7 (Sunday).");
5666
5768
  if (!CHANNELS.includes(s.channel)) throw new Error("The channel must be telegram, whatsapp or sms.");
5667
5769
  if (typeof s.broker !== "string" || !/^[a-z0-9-]{1,40}$/.test(s.broker)) throw new Error("That is not a broker id.");
5668
- if (this.list().length >= MAX_SCHEDULES) throw new Error(`There can be at most ${MAX_SCHEDULES} schedules.`);
5770
+ const limit = this.max();
5771
+ if (this.list().length >= limit) throw new Error(`There can be at most ${limit} schedules.`);
5669
5772
  const info = this.db.prepare("insert into report_schedules (name, broker, kind, time, days, channel, commentary, enabled) values (?, ?, ?, ?, ?, ?, ?, ?)").run(name, s.broker, s.kind, s.time, days.join(","), s.channel, s.commentary ? 1 : 0, s.enabled ? 1 : 0);
5670
5773
  return this.list().find((x) => x.id === Number(info.lastInsertRowid));
5671
5774
  }
@@ -5890,16 +5993,18 @@ function createZerodha(opts) {
5890
5993
  }
5891
5994
 
5892
5995
  // ../server/src/brokers/hub.ts
5996
+ var reportOptions = (b) => ({ money: { symbol: b.currencySymbol, locale: b.numberLocale }, concentrationPct: b.concentrationPct, bigDropPct: b.bigDropPct });
5893
5997
  var NAMES = { zerodha: "Zerodha" };
5894
5998
  var minutes = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5));
5895
5999
  var BrokerHub = class {
5896
6000
  constructor(d) {
5897
6001
  this.d = d;
5898
- this.schedules = new ReportSchedules(d.db);
6002
+ this.schedules = new ReportSchedules(d.db, () => d.config.brokers.maxSchedules);
5899
6003
  this.sessions = new BrokerSessions(d.dataDir, this.now);
5900
6004
  this.scheduler = new ReportScheduler({
5901
6005
  schedules: this.schedules,
5902
6006
  zone: () => d.config.brokers.timezone,
6007
+ maxTries: () => d.config.brokers.maxTriesPerDay,
5903
6008
  skipDates: () => d.config.brokers.skipDates,
5904
6009
  run: (s) => this.deliver(s)
5905
6010
  });
@@ -5924,7 +6029,7 @@ ${z12.apiSecret}`;
5924
6029
  if (!usable) this.providers.delete("zerodha");
5925
6030
  else if (!have || have.made !== made) {
5926
6031
  const provider = createZerodha({ apiKey: z12.apiKey, apiSecret: z12.apiSecret, fetch: this.d.fetch, clock: this.now });
5927
- this.providers.set("zerodha", { provider, portfolio: new Portfolio(provider, this.now), made });
6032
+ this.providers.set("zerodha", { provider, portfolio: new Portfolio(provider, this.now, () => this.d.config.brokers.cacheSeconds * 1e3), made });
5928
6033
  }
5929
6034
  const now = this.providers.get("zerodha");
5930
6035
  if (now) {
@@ -6007,7 +6112,7 @@ ${z12.apiSecret}`;
6007
6112
  async report(id2, kind = "custom") {
6008
6113
  const got = await this.snapshot(id2);
6009
6114
  if (!got.ok) return got;
6010
- const text2 = buildReport({ snapshot: got.snapshot, kind, caps: got.caps, now: new Date(this.now()), broker: got.broker, zone: got.zone });
6115
+ const text2 = buildReport({ snapshot: got.snapshot, kind, caps: got.caps, now: new Date(this.now()), broker: got.broker, zone: got.zone, options: reportOptions(this.d.config.brokers) });
6011
6116
  this.d.audit.log("system", "broker.report", { broker: id2, kind, lines: text2.split("\n").length });
6012
6117
  return { ok: true, text: text2 };
6013
6118
  }
@@ -6741,8 +6846,8 @@ function createGateway(deps) {
6741
6846
  try {
6742
6847
  pairLimiter.check(key);
6743
6848
  } catch (e) {
6744
- const ms = e instanceof LockoutError ? e.retryAfterMs : 6e4;
6745
- return reply.code(429).header("retry-after", Math.ceil(ms / 1e3)).send({ error: "Too many attempts" });
6849
+ const ms2 = e instanceof LockoutError ? e.retryAfterMs : 6e4;
6850
+ return reply.code(429).header("retry-after", Math.ceil(ms2 / 1e3)).send({ error: "Too many attempts" });
6746
6851
  }
6747
6852
  try {
6748
6853
  const token = auth.redeemPairCode(body.data.code, body.data.name);
@@ -6961,11 +7066,16 @@ function createGateway(deps) {
6961
7066
  return brokerError(reply, e);
6962
7067
  }
6963
7068
  });
6964
- const alexaWindow = new SlidingWindow(60, 6e4);
7069
+ let alexaLimit = config.alexa.requestsPerMinute;
7070
+ let alexaWindow = new SlidingWindow(alexaLimit, 6e4);
6965
7071
  void app.register(async (inst) => {
6966
7072
  inst.removeContentTypeParser("application/json");
6967
7073
  inst.addContentTypeParser("application/json", { parseAs: "buffer", bodyLimit: 32 * 1024 }, (_req, body, done) => done(null, body));
6968
7074
  inst.addHook("onRequest", async (req, reply) => {
7075
+ if (config.alexa.requestsPerMinute !== alexaLimit) {
7076
+ alexaLimit = config.alexa.requestsPerMinute;
7077
+ alexaWindow = new SlidingWindow(alexaLimit, 6e4);
7078
+ }
6969
7079
  if (!alexaWindow.allow(`alexa:${req.ip}`)) return reply.code(429).header("retry-after", 30).send();
6970
7080
  });
6971
7081
  inst.post("/api/alexa", async (req, reply) => {
@@ -8255,13 +8365,29 @@ var SettingsPatch = z11.object({
8255
8365
  apiSecret: z11.string().trim().max(100)
8256
8366
  }).partial().strict(),
8257
8367
  timezone: z11.string().trim().min(1).max(60),
8368
+ currencySymbol: z11.string().trim().min(1).max(4),
8369
+ currencyName: z11.string().trim().min(1).max(20),
8370
+ numberLocale: z11.string().trim().min(2).max(35),
8371
+ concentrationPct: z11.number().int().min(5).max(90),
8372
+ bigDropPct: z11.number().int().min(1).max(50),
8373
+ cacheSeconds: z11.number().int().min(5).max(300),
8374
+ maxSchedules: z11.number().int().min(1).max(50),
8375
+ maxTriesPerDay: z11.number().int().min(1).max(10),
8258
8376
  skipDates: z11.array(z11.string().regex(/^\d{4}-\d{2}-\d{2}$/, "A date like 2026-10-02")).max(100),
8259
8377
  loginReminderMinutes: z11.number().int().min(0).max(120)
8260
8378
  }).partial().strict(),
8261
8379
  /** The owner's Alexa skill. The skill id is not a secret; both need the PIN to change. */
8262
8380
  alexa: z11.object({
8263
8381
  skillId: z11.string().trim().max(100).regex(/^(amzn1\.ask\.skill\.[A-Za-z0-9-]{8,80})?$/, "A skill id like amzn1.ask.skill.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"),
8264
- invocationName: z11.string().trim().max(60)
8382
+ invocationName: z11.string().trim().max(60),
8383
+ skillName: z11.string().trim().min(1).max(50),
8384
+ locales: z11.array(z11.enum(ALEXA_LOCALES)).min(1).max(ALEXA_LOCALES.length),
8385
+ answerSeconds: z11.number().min(ALEXA_LIMITS.answerSeconds.min).max(ALEXA_LIMITS.answerSeconds.max),
8386
+ holdMinutes: z11.number().int().min(ALEXA_LIMITS.holdMinutes.min).max(ALEXA_LIMITS.holdMinutes.max),
8387
+ permissionSeconds: z11.number().int().min(ALEXA_LIMITS.permissionSeconds.min).max(ALEXA_LIMITS.permissionSeconds.max),
8388
+ requestsPerMinute: z11.number().int().min(ALEXA_LIMITS.requestsPerMinute.min).max(ALEXA_LIMITS.requestsPerMinute.max),
8389
+ maxDevices: z11.number().int().min(ALEXA_LIMITS.maxDevices.min).max(ALEXA_LIMITS.maxDevices.max),
8390
+ extraVoiceTools: z11.array(z11.string().regex(TOOL_NAME, "A tool name like calendar.list")).max(20)
8265
8391
  }).partial().strict(),
8266
8392
  /** What the assistant may do with money. Only "read" exists in this version; any other level is refused. */
8267
8393
  trading: z11.object({ level: z11.enum(["read"]) }).partial().strict(),
@@ -8323,7 +8449,15 @@ function settingsView(c) {
8323
8449
  zerodha: { installed: c.brokers.zerodha.installed, enabled: c.brokers.zerodha.enabled, apiKey: c.brokers.zerodha.apiKey, secretSet: !!c.brokers.zerodha.apiSecret },
8324
8450
  timezone: c.brokers.timezone,
8325
8451
  skipDates: [...c.brokers.skipDates],
8326
- loginReminderMinutes: c.brokers.loginReminderMinutes
8452
+ loginReminderMinutes: c.brokers.loginReminderMinutes,
8453
+ currencySymbol: c.brokers.currencySymbol,
8454
+ currencyName: c.brokers.currencyName,
8455
+ numberLocale: c.brokers.numberLocale,
8456
+ concentrationPct: c.brokers.concentrationPct,
8457
+ bigDropPct: c.brokers.bigDropPct,
8458
+ cacheSeconds: c.brokers.cacheSeconds,
8459
+ maxSchedules: c.brokers.maxSchedules,
8460
+ maxTriesPerDay: c.brokers.maxTriesPerDay
8327
8461
  },
8328
8462
  trading: { level: c.trading.level },
8329
8463
  alexa: {
@@ -8331,7 +8465,15 @@ function settingsView(c) {
8331
8465
  enabled: c.alexa.enabled,
8332
8466
  skillId: c.alexa.skillId,
8333
8467
  invocationName: c.alexa.invocationName,
8334
- allowed: c.alexa.allowedUsers.map((u) => ({ id: "\u2026" + u.id.slice(-8), name: u.name }))
8468
+ allowed: c.alexa.allowedUsers.map((u) => ({ id: "\u2026" + u.id.slice(-8), name: u.name })),
8469
+ skillName: c.alexa.skillName,
8470
+ locales: [...c.alexa.locales],
8471
+ answerSeconds: c.alexa.answerSeconds,
8472
+ holdMinutes: c.alexa.holdMinutes,
8473
+ permissionSeconds: c.alexa.permissionSeconds,
8474
+ requestsPerMinute: c.alexa.requestsPerMinute,
8475
+ maxDevices: c.alexa.maxDevices,
8476
+ extraVoiceTools: [...c.alexa.extraVoiceTools]
8335
8477
  },
8336
8478
  look: { ...c.look },
8337
8479
  ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
@@ -8373,6 +8515,10 @@ function applySettings(config, raw) {
8373
8515
  }
8374
8516
  const p = parsed.data;
8375
8517
  if (p.brokers?.timezone !== void 0 && !isValidTimeZone(p.brokers.timezone)) throw new SettingsError("brokers.timezone: That is not a time zone, try one like Asia/Kolkata.");
8518
+ if (p.brokers?.numberLocale !== void 0 && !validNumberLocale(p.brokers.numberLocale)) throw new SettingsError("brokers.numberLocale: That is not a number format this PC knows, try one like en-IN or en-US.");
8519
+ for (const t of p.alexa?.extraVoiceTools ?? []) {
8520
+ if (isNeverByVoice(t)) throw new SettingsError(`alexa.extraVoiceTools: ${t} can never be used by voice (it reaches your PC, files, settings, add-ons or camera).`);
8521
+ }
8376
8522
  if (p.alexa?.invocationName) {
8377
8523
  const problem = invocationNameProblem(p.alexa.invocationName);
8378
8524
  if (problem) throw new SettingsError(`alexa.invocationName: ${problem}`);
@@ -8429,11 +8575,17 @@ function applySettings(config, raw) {
8429
8575
  if (rest.timezone !== void 0) config.brokers.timezone = rest.timezone;
8430
8576
  if (rest.skipDates !== void 0) config.brokers.skipDates = sanitizeBrokers({ skipDates: rest.skipDates }).skipDates;
8431
8577
  if (rest.loginReminderMinutes !== void 0) config.brokers.loginReminderMinutes = rest.loginReminderMinutes;
8578
+ for (const k of ["currencySymbol", "currencyName", "numberLocale", "concentrationPct", "bigDropPct", "cacheSeconds", "maxSchedules", "maxTriesPerDay"]) {
8579
+ if (rest[k] !== void 0) config.brokers[k] = rest[k];
8580
+ }
8432
8581
  }
8433
8582
  if (p.alexa) {
8434
8583
  config.alexa.installed = true;
8435
8584
  if (p.alexa.skillId !== void 0) config.alexa.skillId = p.alexa.skillId;
8436
8585
  if (p.alexa.invocationName !== void 0) config.alexa.invocationName = p.alexa.invocationName;
8586
+ for (const k of ["skillName", "locales", "answerSeconds", "holdMinutes", "permissionSeconds", "requestsPerMinute", "maxDevices", "extraVoiceTools"]) {
8587
+ if (p.alexa[k] !== void 0) config.alexa[k] = p.alexa[k];
8588
+ }
8437
8589
  }
8438
8590
  if (p.approvals) config.approvals = p.approvals;
8439
8591
  if (p.channels) {
@@ -9726,7 +9878,7 @@ async function createApp(opts) {
9726
9878
  const got = await brokerHub.snapshot("zerodha");
9727
9879
  if (!got.ok) return got.sessionExpired ? "Zerodha needs you to log in first." : got.error;
9728
9880
  if (!got.snapshot.holdings.ok) return "I could not read your holdings just now.";
9729
- return portfolioSpeech(summarize(got.snapshot.holdings.data), locale2.startsWith("hi") ? "hi" : "en");
9881
+ 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 });
9730
9882
  },
9731
9883
  WeatherIntent: async (_locale, slots) => {
9732
9884
  if (!slots.city) return "Tell me which city, for example: what is the weather in Pune.";
@@ -10213,9 +10365,10 @@ async function createApp(opts) {
10213
10365
  },
10214
10366
  packageFile: (name) => {
10215
10367
  const endpoint = alexaHub.endpoint();
10216
- if (!PACKAGE_FILES.includes(name) || !endpoint || !config.alexa.invocationName) return null;
10368
+ if (!packageFileNames(config.alexa.locales).includes(name) || !endpoint || !config.alexa.invocationName) return null;
10217
10369
  try {
10218
- return { type: "application/json", text: buildSkillPackage({ invocationName: config.alexa.invocationName, endpoint })[name] };
10370
+ const text2 = buildSkillPackage({ invocationName: config.alexa.invocationName, endpoint, skillName: config.alexa.skillName, locales: config.alexa.locales })[name];
10371
+ return text2 ? { type: "application/json", text: text2 } : null;
10219
10372
  } catch {
10220
10373
  return null;
10221
10374
  }
@@ -11495,7 +11648,7 @@ async function startFriend(opts) {
11495
11648
  async stop() {
11496
11649
  if (watcher) clearInterval(watcher);
11497
11650
  node.stop();
11498
- const wait = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
11651
+ const wait = (ms2) => new Promise((r) => setTimeout(r, ms2).unref?.());
11499
11652
  await Promise.race([Promise.allSettled([sidecar?.stop(), app.stop()]), wait(6e3)]);
11500
11653
  }
11501
11654
  };
@@ -12672,7 +12825,7 @@ import { connect } from "node:net";
12672
12825
  import { join as join19 } from "node:path";
12673
12826
  var USAGE3 = "Usage: friend daemon start | stop | restart | status";
12674
12827
  var MAX_LOG_BYTES = 5 * 1024 * 1024;
12675
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12828
+ var sleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
12676
12829
  var realDeps = {
12677
12830
  launch(logFile) {
12678
12831
  const fd = openSync2(logFile, "a");
@@ -12741,8 +12894,8 @@ function running(dataDir, deps) {
12741
12894
  if (r) rmSync7(pidFile(dataDir), { force: true });
12742
12895
  return null;
12743
12896
  }
12744
- function since(ms) {
12745
- const s = Math.max(0, Math.round(ms / 1e3));
12897
+ function since(ms2) {
12898
+ const s = Math.max(0, Math.round(ms2 / 1e3));
12746
12899
  if (s < 90) return `${s} seconds`;
12747
12900
  if (s < 5400) return `${Math.round(s / 60)} minutes`;
12748
12901
  if (s < 129600) return `${Math.round(s / 3600)} hours`;
@@ -12838,7 +12991,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
12838
12991
  import crossSpawn5 from "cross-spawn";
12839
12992
  var PACKAGE = "friend-assistant";
12840
12993
  var real = {
12841
- current: () => true ? "0.1.24" : "development",
12994
+ current: () => true ? "0.1.26" : "development",
12842
12995
  latest: () => new Promise((resolve11) => {
12843
12996
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
12844
12997
  let out = "";
@@ -13126,7 +13279,7 @@ async function run(argv, io2) {
13126
13279
  case "version":
13127
13280
  case "--version":
13128
13281
  case "-v":
13129
- io2.out(true ? "0.1.24" : "development");
13282
+ io2.out(true ? "0.1.26" : "development");
13130
13283
  return 0;
13131
13284
  case "lan":
13132
13285
  return await lanCommand(rest, io2);