alvin-bot 4.6.0 โ†’ 4.8.0

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.
@@ -18,6 +18,7 @@ import { screenshotUrl, extractText, generatePdf, hasPlaywright } from "../servi
18
18
  import { listJobs, createJob, deleteJob, toggleJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
19
19
  import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "../services/sudo.js";
20
20
  import { config } from "../config.js";
21
+ import { BOT_VERSION } from "../version.js";
21
22
  import { getWebPort } from "../web/server.js";
22
23
  import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
23
24
  import { runUpdate, getAutoUpdate, setAutoUpdate, startAutoUpdateLoop } from "../services/updater.js";
@@ -141,6 +142,7 @@ export function registerCommands(bot) {
141
142
  { command: "effort", description: "Set reasoning depth" },
142
143
  { command: "voice", description: "Voice replies on/off" },
143
144
  { command: "status", description: "Current status" },
145
+ { command: "version", description: "Show Alvin Bot version" },
144
146
  { command: "new", description: "Start new session" },
145
147
  { command: "dir", description: "Change working directory" },
146
148
  { command: "web", description: "Quick web search" },
@@ -219,6 +221,10 @@ export function registerCommands(bot) {
219
221
  await ctx.reply(`Directory not found: ${resolved}`);
220
222
  }
221
223
  });
224
+ bot.command("version", async (ctx) => {
225
+ await ctx.reply(`๐Ÿค– *Alvin Bot* \`v${BOT_VERSION}\`\n` +
226
+ `Node ${process.version} ยท ${process.platform}/${process.arch}`, { parse_mode: "Markdown" });
227
+ });
222
228
  bot.command("status", async (ctx) => {
223
229
  const userId = ctx.from.id;
224
230
  const session = getSession(userId);
@@ -371,7 +377,7 @@ export function registerCommands(bot) {
371
377
  const failoverBadge = failedOver ? ` ${t("bot.status.failedOver", lang)}` : "";
372
378
  healthLines = `\n${t("bot.status.providerHealth", lang)}${failoverBadge}\n${rows.join("\n")}\n`;
373
379
  }
374
- await ctx.reply(`๐Ÿค– *Alvin Bot Status*\n\n` +
380
+ await ctx.reply(`๐Ÿค– *Alvin Bot* \`v${BOT_VERSION}\`\n\n` +
375
381
  `*Model:* ${info.name} ${providerTag}\n` +
376
382
  `*Effort:* ${EFFORT_LABELS[session.effort]}\n` +
377
383
  `*Voice:* ${session.voiceReply ? "on" : "off"}\n` +
@@ -1728,7 +1734,7 @@ export function registerCommands(bot) {
1728
1734
  // type both "/sub-agents" and "/subagents" โ€” Telegram routes both to this.
1729
1735
  bot.command(["sub_agents", "subagents"], async (ctx) => {
1730
1736
  const lang = getSession(ctx.from.id).language;
1731
- const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, } = await import("../services/subagents.js");
1737
+ const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, } = await import("../services/subagents.js");
1732
1738
  const arg = (ctx.match || "").trim();
1733
1739
  const tokens = arg.split(/\s+/).filter(Boolean);
1734
1740
  const sub = tokens[0]?.toLowerCase() || "";
@@ -1741,7 +1747,8 @@ export function registerCommands(bot) {
1741
1747
  const ageLabel = ageSec < 60 ? `${ageSec}s` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m` : `${Math.floor(ageSec / 3600)}h`;
1742
1748
  const sourceBadge = a.source === "cron" ? "โฐ" : a.source === "implicit" ? "๐Ÿ”—" : "๐Ÿ‘ค";
1743
1749
  const depthTag = a.depth > 0 ? ` d${a.depth}` : "";
1744
- return `${indent}${sourceBadge} \`${shortId(a.id)}\` ${a.name} (${a.status}, ${ageLabel}${depthTag})`;
1750
+ const queueTag = a.status === "queued" && a.queuePosition ? ` #${a.queuePosition}` : "";
1751
+ return `${indent}${sourceBadge} \`${shortId(a.id)}\` ${a.name} (${a.status}${queueTag}, ${ageLabel}${depthTag})`;
1745
1752
  };
1746
1753
  // /sub-agents max <n>
1747
1754
  if (sub === "max") {
@@ -1754,7 +1761,50 @@ export function registerCommands(bot) {
1754
1761
  await ctx.reply(t("bot.subagents.maxSet", lang, { n, eff: effective }), { parse_mode: "Markdown" });
1755
1762
  return;
1756
1763
  }
1757
- // /sub-agents visibility <auto|banner|silent>
1764
+ // /subagents stats โ€” show rolling 24h run stats (H3)
1765
+ if (sub === "stats") {
1766
+ const { getSubAgentStats } = await import("../services/subagent-stats.js");
1767
+ const s = getSubAgentStats();
1768
+ const formatTok = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
1769
+ const formatDur = (ms) => {
1770
+ const sec = Math.floor(ms / 1000);
1771
+ if (sec < 60)
1772
+ return `${sec}s`;
1773
+ const m = Math.floor(sec / 60);
1774
+ return `${m}m`;
1775
+ };
1776
+ const lines = [
1777
+ `๐Ÿ“Š *Sub-Agent Stats* โ€” last ${s.windowHours}h`,
1778
+ ``,
1779
+ `*Total:* ${s.total.runs} runs ยท ${formatTok(s.total.inputTokens)} in / ${formatTok(s.total.outputTokens)} out ยท ${formatDur(s.total.totalDurationMs)}`,
1780
+ ``,
1781
+ `*By source:*`,
1782
+ ` ๐Ÿ‘ค user: ${s.bySource.user.runs} runs ยท ${formatTok(s.bySource.user.inputTokens)} in / ${formatTok(s.bySource.user.outputTokens)} out`,
1783
+ ` โฐ cron: ${s.bySource.cron.runs} runs ยท ${formatTok(s.bySource.cron.inputTokens)} in / ${formatTok(s.bySource.cron.outputTokens)} out`,
1784
+ ` ๐Ÿ”— implicit: ${s.bySource.implicit.runs} runs ยท ${formatTok(s.bySource.implicit.inputTokens)} in / ${formatTok(s.bySource.implicit.outputTokens)} out`,
1785
+ ``,
1786
+ `*By status:*`,
1787
+ ` โœ… completed: ${s.byStatus.completed}`,
1788
+ ` โš ๏ธ cancelled: ${s.byStatus.cancelled}`,
1789
+ ` โฑ๏ธ timeout: ${s.byStatus.timeout}`,
1790
+ ` โŒ error: ${s.byStatus.error}`,
1791
+ ];
1792
+ await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
1793
+ return;
1794
+ }
1795
+ // /subagents queue <n> โ€” set bounded-queue cap (0 disables queue)
1796
+ if (sub === "queue") {
1797
+ const n = parseInt(tokens[1] || "", 10);
1798
+ if (isNaN(n)) {
1799
+ const current = getQueueCap();
1800
+ await ctx.reply(`Queue cap: *${current}* (${current === 0 ? "disabled" : "bounded"})\nUsage: \`/subagents queue <n>\` (0 disables the queue, max 200)`, { parse_mode: "Markdown" });
1801
+ return;
1802
+ }
1803
+ const effective = setQueueCap(n);
1804
+ await ctx.reply(`โœ… Queue cap set to *${effective}* ${effective === 0 ? "(queue disabled โ€” full pool rejects immediately)" : ""}`, { parse_mode: "Markdown" });
1805
+ return;
1806
+ }
1807
+ // /sub-agents visibility <auto|banner|silent|live>
1758
1808
  if (sub === "visibility") {
1759
1809
  const mode = tokens[1];
1760
1810
  if (!mode) {
package/dist/i18n.js CHANGED
@@ -519,10 +519,10 @@ const strings = {
519
519
  fr: "Durรฉe : {sec}s ยท Tokens : {in}/{out}",
520
520
  },
521
521
  "bot.subagents.usage": {
522
- en: "Commands:\n/subagents โ€” show status\n/subagents max <n> โ€” set parallel limit (0=auto)\n/subagents visibility <auto|banner|silent> โ€” delivery mode\n/subagents list โ€” list all\n/subagents cancel <name|id> โ€” cancel one\n/subagents result <name|id> โ€” show result",
523
- de: "Befehle:\n/subagents โ€” Status anzeigen\n/subagents max <n> โ€” Parallel-Limit setzen (0=auto)\n/subagents visibility <auto|banner|silent> โ€” Delivery-Modus\n/subagents list โ€” alle anzeigen\n/subagents cancel <name|id> โ€” abbrechen\n/subagents result <name|id> โ€” Ergebnis anzeigen",
524
- es: "Comandos:\n/subagents โ€” ver estado\n/subagents max <n> โ€” establecer lรญmite (0=auto)\n/subagents visibility <auto|banner|silent> โ€” modo de entrega\n/subagents list โ€” listar todos\n/subagents cancel <nombre|id> โ€” cancelar uno\n/subagents result <nombre|id> โ€” ver resultado",
525
- fr: "Commandes :\n/subagents โ€” รฉtat\n/subagents max <n> โ€” limite parallรจle (0=auto)\n/subagents visibility <auto|banner|silent> โ€” mode de livraison\n/subagents list โ€” lister tous\n/subagents cancel <nom|id> โ€” annuler un\n/subagents result <nom|id> โ€” voir rรฉsultat",
522
+ en: "Commands:\n/subagents โ€” show status\n/subagents max <n> โ€” set parallel limit (0=auto)\n/subagents visibility <auto|banner|silent|live> โ€” delivery mode\n/subagents queue <n> โ€” bounded-queue cap (0 = disabled)\n/subagents stats โ€” last 24h run stats\n/subagents list โ€” list all\n/subagents cancel <name|id> โ€” cancel one\n/subagents result <name|id> โ€” show result",
523
+ de: "Befehle:\n/subagents โ€” Status anzeigen\n/subagents max <n> โ€” Parallel-Limit setzen (0=auto)\n/subagents visibility <auto|banner|silent|live> โ€” Delivery-Modus\n/subagents list โ€” alle anzeigen\n/subagents cancel <name|id> โ€” abbrechen\n/subagents result <name|id> โ€” Ergebnis anzeigen",
524
+ es: "Comandos:\n/subagents โ€” ver estado\n/subagents max <n> โ€” establecer lรญmite (0=auto)\n/subagents visibility <auto|banner|silent|live> โ€” modo de entrega\n/subagents list โ€” listar todos\n/subagents cancel <nombre|id> โ€” cancelar uno\n/subagents result <nombre|id> โ€” ver resultado",
525
+ fr: "Commandes :\n/subagents โ€” รฉtat\n/subagents max <n> โ€” limite parallรจle (0=auto)\n/subagents visibility <auto|banner|silent|live> โ€” mode de livraison\n/subagents list โ€” lister tous\n/subagents cancel <nom|id> โ€” annuler un\n/subagents result <nom|id> โ€” voir rรฉsultat",
526
526
  },
527
527
  "bot.subagents.visibilityLabel": {
528
528
  en: "Visibility:",
@@ -537,10 +537,10 @@ const strings = {
537
537
  fr: "โœ… Visibilitรฉ rรฉglรฉe sur *{mode}*",
538
538
  },
539
539
  "bot.subagents.visibilityInvalid": {
540
- en: "โŒ Invalid mode _{mode}_. Use: auto | banner | silent",
541
- de: "โŒ Ungรผltiger Modus _{mode}_. Nutze: auto | banner | silent",
542
- es: "โŒ Modo invรกlido _{mode}_. Usa: auto | banner | silent",
543
- fr: "โŒ Mode invalide _{mode}_. Utilise : auto | banner | silent",
540
+ en: "โŒ Invalid mode _{mode}_. Use: auto | banner | silent | live",
541
+ de: "โŒ Ungรผltiger Modus _{mode}_. Nutze: auto | banner | silent | live",
542
+ es: "โŒ Modo invรกlido _{mode}_. Usa: auto | banner | silent | live",
543
+ fr: "โŒ Mode invalide _{mode}_. Utilise : auto | banner | silent | live",
544
544
  },
545
545
  // Relative time formatting (formatRelativeTime helper)
546
546
  "bot.time.justNow": {
package/dist/index.js CHANGED
@@ -134,6 +134,7 @@ if (hasTelegram) {
134
134
  attachBotApi({
135
135
  sendMessage: (chatId, text, opts) => botRef.api.sendMessage(chatId, text, opts),
136
136
  sendDocument: (chatId, doc, opts) => botRef.api.sendDocument(chatId, doc, opts),
137
+ editMessageText: (chatId, messageId, text, opts) => botRef.api.editMessageText(chatId, messageId, text, opts),
137
138
  });
138
139
  // Auth middleware โ€” alle Messages durchlaufen das
139
140
  bot.use(authMiddleware);
@@ -53,6 +53,157 @@ function buildBanner(info, result) {
53
53
  const to = formatTokens(result.tokensUsed.output);
54
54
  return `${icon} *${info.name}* ${result.status} ยท ${dur} ยท ${ti} in / ${to} out`;
55
55
  }
56
+ // โ”€โ”€ A4 Live-Stream โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
57
+ /**
58
+ * Per-spawn live-stream state. Edits a single Telegram message as the
59
+ * sub-agent produces text, throttled to ~800ms between edits. Posts a
60
+ * separate banner message at finalize so the user gets a completion
61
+ * notification (edits don't trigger Telegram notifications).
62
+ *
63
+ * The live message uses plain text (no parse_mode) so half-formed
64
+ * markdown during streaming can never crash the edit. The final banner
65
+ * does use markdown.
66
+ */
67
+ const LIVE_EDIT_THROTTLE_MS = 800;
68
+ const LIVE_INITIAL_TEXT = (name) => `โณ ${name} thinkingโ€ฆ`;
69
+ export class LiveStream {
70
+ api;
71
+ chatId;
72
+ agentName;
73
+ messageId = null;
74
+ lastEditAt = 0;
75
+ pendingText = null;
76
+ pendingTimer = null;
77
+ started = false;
78
+ failed = false;
79
+ constructor(api, chatId, agentName) {
80
+ this.api = api;
81
+ this.chatId = chatId;
82
+ this.agentName = agentName;
83
+ }
84
+ /** Post the initial placeholder message. Called before the first chunk. */
85
+ async start() {
86
+ if (!this.api.editMessageText) {
87
+ this.failed = true;
88
+ console.warn(`[subagent-live] bot api has no editMessageText โ€” falling back`);
89
+ return;
90
+ }
91
+ try {
92
+ const initial = LIVE_INITIAL_TEXT(this.agentName);
93
+ const msg = await this.api.sendMessage(this.chatId, initial);
94
+ const msgId = msg.message_id;
95
+ if (typeof msgId === "number") {
96
+ this.messageId = msgId;
97
+ this.lastEditAt = Date.now();
98
+ this.started = true;
99
+ }
100
+ else {
101
+ console.warn(`[subagent-live] sendMessage returned no message_id`);
102
+ this.failed = true;
103
+ }
104
+ }
105
+ catch (err) {
106
+ console.error(`[subagent-live] start failed:`, err);
107
+ this.failed = true;
108
+ }
109
+ }
110
+ /**
111
+ * Record a new accumulated text state. Will schedule a throttled edit
112
+ * ~800ms after the previous edit. Later updates that arrive before
113
+ * the throttled flush coalesce โ€” only the latest text is used.
114
+ */
115
+ update(text) {
116
+ if (!this.started || this.failed || this.messageId === null)
117
+ return;
118
+ this.pendingText = text;
119
+ if (this.pendingTimer)
120
+ return;
121
+ const elapsed = Date.now() - this.lastEditAt;
122
+ const delay = Math.max(0, LIVE_EDIT_THROTTLE_MS - elapsed);
123
+ this.pendingTimer = setTimeout(() => {
124
+ this.flush().catch((err) => {
125
+ console.warn(`[subagent-live] scheduled flush failed:`, err);
126
+ });
127
+ }, delay);
128
+ }
129
+ async flush() {
130
+ this.pendingTimer = null;
131
+ if (!this.pendingText || this.messageId === null || this.failed)
132
+ return;
133
+ if (!this.api.editMessageText) {
134
+ this.failed = true;
135
+ return;
136
+ }
137
+ // Cap edit length โ€” Telegram rejects >4096 chars
138
+ const body = this.pendingText.slice(0, MAX_TG_CHUNK);
139
+ const display = `โณ ${this.agentName}\n\n${body}`;
140
+ try {
141
+ await this.api.editMessageText(this.chatId, this.messageId, display);
142
+ this.lastEditAt = Date.now();
143
+ }
144
+ catch (err) {
145
+ // "message is not modified" is harmless (same content as before)
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ if (!/not modified/i.test(msg)) {
148
+ console.warn(`[subagent-live] edit failed:`, msg);
149
+ }
150
+ }
151
+ this.pendingText = null;
152
+ }
153
+ /**
154
+ * Flush any pending edit, then post the final banner as a new message
155
+ * so the user gets a notification. The live-stream message stays in
156
+ * place as the body; the banner is a separate message above/below it.
157
+ */
158
+ async finalize(info, result) {
159
+ if (this.pendingTimer) {
160
+ clearTimeout(this.pendingTimer);
161
+ this.pendingTimer = null;
162
+ }
163
+ if (this.pendingText) {
164
+ await this.flush();
165
+ }
166
+ this.started = false;
167
+ if (this.failed)
168
+ return;
169
+ // One last edit to remove the "thinkingโ€ฆ" header (replace with final text)
170
+ if (this.messageId !== null && this.api.editMessageText) {
171
+ const finalBody = (result.output?.trim() || "(empty output)").slice(0, MAX_TG_CHUNK);
172
+ const finalDisplay = `${info.name}\n\n${finalBody}`;
173
+ try {
174
+ await this.api.editMessageText(this.chatId, this.messageId, finalDisplay);
175
+ }
176
+ catch {
177
+ // If the final edit fails, the "thinkingโ€ฆ" header stays โ€”
178
+ // the banner below will still communicate completion.
179
+ }
180
+ }
181
+ // Post the banner as a new message (notification-triggering)
182
+ const banner = buildBanner(info, result);
183
+ try {
184
+ await this.api.sendMessage(this.chatId, banner, { parse_mode: "Markdown" });
185
+ }
186
+ catch (err) {
187
+ console.error(`[subagent-live] finalize banner failed:`, err);
188
+ this.failed = true;
189
+ throw err;
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Factory for LiveStream โ€” returns null if the bot api isn't attached
195
+ * yet, or if the api doesn't support editMessageText. Callers check
196
+ * the return value and fall back to normal delivery if null.
197
+ */
198
+ export function createLiveStream(chatId, agentName) {
199
+ const api = getBotApi();
200
+ if (!api || !api.editMessageText) {
201
+ console.warn(`[subagent-live] no compatible bot api โ€” live mode unavailable`);
202
+ return null;
203
+ }
204
+ return new LiveStream(api, chatId, agentName);
205
+ }
206
+ // โ”€โ”€ Main delivery entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
207
  /**
57
208
  * Main delivery entry point. Resolves the effective visibility (override โ†’
58
209
  * config default), then dispatches to the source-specific renderer.
@@ -68,6 +219,10 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
68
219
  const effective = opts.visibility ?? getVisibility();
69
220
  if (effective === "silent")
70
221
  return;
222
+ // "live" mode is handled inline by runSubAgent via LiveStream. If we
223
+ // get here with "live" visibility it means the live-stream path wasn't
224
+ // applicable (wrong source, missing editMessageText, etc.) โ€” fall
225
+ // through to the normal banner+final behavior below.
71
226
  const api = getBotApi();
72
227
  if (!api) {
73
228
  console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Sub-Agent Stats (H3) โ€” rolling 24h aggregation of per-agent run data.
3
+ *
4
+ * Append-only JSON ring buffer persisted to ~/.alvin-bot/subagent-stats.json.
5
+ * On load, entries older than 24h are pruned. On each append, entries older
6
+ * than 24h are pruned.
7
+ *
8
+ * Used by /subagents stats to show run totals per source (user, cron, implicit)
9
+ * over the last 24 hours. No SQLite dependency โ€” when a real SQLite migration
10
+ * lands we can swap the backend without touching the consumer API.
11
+ */
12
+ import os from "os";
13
+ import fs from "fs";
14
+ import { resolve, dirname } from "path";
15
+ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot");
16
+ const STATS_FILE = resolve(DATA_DIR, "subagent-stats.json");
17
+ const WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
18
+ const MAX_ENTRIES = 5000; // hard cap to prevent unbounded growth on high-frequency bots
19
+ let cache = null;
20
+ function load() {
21
+ if (cache)
22
+ return cache;
23
+ try {
24
+ const raw = fs.readFileSync(STATS_FILE, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ if (!Array.isArray(parsed)) {
27
+ cache = [];
28
+ return cache;
29
+ }
30
+ // Prune stale entries (> 24h old) on load
31
+ const cutoff = Date.now() - WINDOW_MS;
32
+ cache = parsed.filter((e) => typeof e === "object" &&
33
+ e !== null &&
34
+ typeof e.completedAt === "number" &&
35
+ e.completedAt >= cutoff);
36
+ return cache;
37
+ }
38
+ catch {
39
+ cache = [];
40
+ return cache;
41
+ }
42
+ }
43
+ function save(entries) {
44
+ try {
45
+ fs.mkdirSync(dirname(STATS_FILE), { recursive: true });
46
+ fs.writeFileSync(STATS_FILE, JSON.stringify(entries, null, 0), "utf-8");
47
+ }
48
+ catch (err) {
49
+ console.error("[subagent-stats] failed to write:", err);
50
+ }
51
+ }
52
+ /**
53
+ * Record a completed sub-agent run. Called from runSubAgent.finally() via
54
+ * a side-effect hook. Automatically prunes entries older than 24h and
55
+ * keeps the file bounded at MAX_ENTRIES.
56
+ */
57
+ export function recordSubAgentRun(info, result) {
58
+ const entries = load();
59
+ const cutoff = Date.now() - WINDOW_MS;
60
+ // Prune in-place
61
+ const pruned = entries.filter((e) => e.completedAt >= cutoff);
62
+ const newEntry = {
63
+ completedAt: Date.now(),
64
+ name: info.name,
65
+ source: (info.source ?? "implicit"),
66
+ status: result.status,
67
+ durationMs: result.duration,
68
+ inputTokens: result.tokensUsed.input,
69
+ outputTokens: result.tokensUsed.output,
70
+ };
71
+ pruned.push(newEntry);
72
+ // Enforce hard cap โ€” oldest entries drop first
73
+ const final = pruned.length > MAX_ENTRIES ? pruned.slice(-MAX_ENTRIES) : pruned;
74
+ cache = final;
75
+ save(final);
76
+ }
77
+ /**
78
+ * Compute a summary of the last 24h of sub-agent runs. Safe to call
79
+ * concurrently with recordSubAgentRun โ€” both read from the same cache.
80
+ */
81
+ export function getSubAgentStats() {
82
+ const entries = load();
83
+ const cutoff = Date.now() - WINDOW_MS;
84
+ const recent = entries.filter((e) => e.completedAt >= cutoff);
85
+ const empty = () => ({
86
+ runs: 0,
87
+ inputTokens: 0,
88
+ outputTokens: 0,
89
+ totalDurationMs: 0,
90
+ });
91
+ const bySource = {
92
+ user: empty(),
93
+ cron: empty(),
94
+ implicit: empty(),
95
+ };
96
+ const byStatus = {
97
+ completed: 0,
98
+ timeout: 0,
99
+ error: 0,
100
+ cancelled: 0,
101
+ };
102
+ const total = empty();
103
+ for (const e of recent) {
104
+ const bucket = bySource[e.source] ?? bySource.implicit;
105
+ bucket.runs += 1;
106
+ bucket.inputTokens += e.inputTokens;
107
+ bucket.outputTokens += e.outputTokens;
108
+ bucket.totalDurationMs += e.durationMs;
109
+ total.runs += 1;
110
+ total.inputTokens += e.inputTokens;
111
+ total.outputTokens += e.outputTokens;
112
+ total.totalDurationMs += e.durationMs;
113
+ byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
114
+ }
115
+ return { windowHours: 24, total, bySource, byStatus };
116
+ }
117
+ /**
118
+ * Reset the in-memory cache โ€” for test isolation. Does NOT delete the
119
+ * file; use ALVIN_DATA_DIR in tests to point at a fresh temp dir.
120
+ */
121
+ export function __resetStatsCacheForTest() {
122
+ cache = null;
123
+ }