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.
- package/CHANGELOG.md +191 -0
- package/bin/cli.js +314 -27
- package/dist/handlers/commands.js +54 -4
- package/dist/i18n.js +8 -8
- package/dist/index.js +1 -0
- package/dist/services/subagent-delivery.js +155 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +225 -72
- package/dist/tui/index.js +8 -1
- package/dist/version.js +24 -0
- package/dist/web/server.js +2 -1
- package/docs/HANDBOOK.md +39 -2
- package/package.json +1 -1
- package/test/subagent-delivery.test.ts +104 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-config.test.ts +7 -1
- package/test/subagents-priority-reject.test.ts +29 -1
- package/test/subagents-queue.test.ts +127 -0
- package/alvin-bot-4.5.1.tgz +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
// /
|
|
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
|
+
}
|