alvin-bot 4.5.1 → 4.7.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 +278 -0
- package/README.md +25 -2
- package/bin/cli.js +325 -26
- package/dist/handlers/commands.js +505 -63
- package/dist/handlers/message.js +209 -14
- package/dist/i18n.js +470 -13
- package/dist/index.js +45 -5
- package/dist/providers/claude-sdk-provider.js +106 -14
- package/dist/providers/ollama-provider.js +32 -0
- package/dist/providers/openai-compatible.js +10 -1
- package/dist/providers/registry.js +112 -17
- package/dist/providers/types.js +25 -3
- package/dist/services/compaction.js +2 -0
- package/dist/services/cron.js +53 -42
- package/dist/services/heartbeat.js +41 -7
- package/dist/services/language-detect.js +12 -2
- package/dist/services/ollama-manager.js +339 -0
- package/dist/services/personality.js +20 -14
- package/dist/services/session.js +21 -3
- package/dist/services/subagent-delivery.js +266 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +509 -42
- package/dist/services/telegram.js +28 -1
- package/dist/services/updater.js +158 -0
- package/dist/services/usage-tracker.js +11 -4
- package/dist/services/users.js +2 -1
- package/docs/HANDBOOK.md +856 -0
- package/package.json +7 -2
- package/test/claude-sdk-provider.test.ts +69 -0
- package/test/i18n.test.ts +108 -0
- package/test/registry.test.ts +201 -0
- package/test/subagent-delivery.test.ts +273 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-commands.test.ts +64 -0
- package/test/subagents-config.test.ts +114 -0
- package/test/subagents-depth.test.ts +58 -0
- package/test/subagents-inheritance.test.ts +67 -0
- package/test/subagents-name-resolver.test.ts +122 -0
- package/test/subagents-priority-reject.test.ts +88 -0
- package/test/subagents-queue.test.ts +127 -0
- package/test/subagents-shutdown.test.ts +126 -0
- package/test/subagents-toolset.test.ts +51 -0
- package/vitest.config.ts +17 -0
package/dist/services/session.js
CHANGED
|
@@ -27,15 +27,20 @@ export function getSession(key) {
|
|
|
27
27
|
totalCost: 0,
|
|
28
28
|
costByProvider: {},
|
|
29
29
|
queriesByProvider: {},
|
|
30
|
-
effort: "
|
|
30
|
+
effort: "medium",
|
|
31
31
|
voiceReply: false,
|
|
32
32
|
messageCount: 0,
|
|
33
33
|
toolUseCount: 0,
|
|
34
34
|
totalInputTokens: 0,
|
|
35
35
|
totalOutputTokens: 0,
|
|
36
|
+
lastTurnInputTokens: 0,
|
|
37
|
+
compactionCount: 0,
|
|
38
|
+
checkpointHintsInjected: 0,
|
|
39
|
+
sdkSubTaskCount: 0,
|
|
36
40
|
history: [],
|
|
37
41
|
language: "en",
|
|
38
42
|
messageQueue: [],
|
|
43
|
+
lastSdkHistoryIndex: -1,
|
|
39
44
|
};
|
|
40
45
|
sessions.set(k, session);
|
|
41
46
|
}
|
|
@@ -56,7 +61,12 @@ export function resetSession(key) {
|
|
|
56
61
|
session.toolUseCount = 0;
|
|
57
62
|
session.totalInputTokens = 0;
|
|
58
63
|
session.totalOutputTokens = 0;
|
|
64
|
+
session.lastTurnInputTokens = 0;
|
|
65
|
+
session.compactionCount = 0;
|
|
66
|
+
session.checkpointHintsInjected = 0;
|
|
67
|
+
session.sdkSubTaskCount = 0;
|
|
59
68
|
session.history = [];
|
|
69
|
+
session.lastSdkHistoryIndex = -1;
|
|
60
70
|
session.startedAt = Date.now();
|
|
61
71
|
// Reset budget warning flags so the user gets fresh warnings in the new session.
|
|
62
72
|
session._budgetWarned80 = false;
|
|
@@ -138,13 +148,21 @@ export function stopSessionCleanup() {
|
|
|
138
148
|
cleanupTimer = null;
|
|
139
149
|
}
|
|
140
150
|
}
|
|
141
|
-
/** Add a message to conversation history
|
|
151
|
+
/** Add a message to conversation history. Unified across all provider types
|
|
152
|
+
* — SDK providers resume from their filesystem session but we still track the
|
|
153
|
+
* transcript here so failovers (and the B2 bridge-message) have context. */
|
|
142
154
|
export function addToHistory(key, message) {
|
|
143
155
|
const session = getSession(key);
|
|
144
156
|
session.history.push(message);
|
|
145
|
-
// Trim oldest messages if history gets too long
|
|
157
|
+
// Trim oldest messages if history gets too long. Adjust lastSdkHistoryIndex
|
|
158
|
+
// by the number of dropped entries so it keeps pointing at the correct
|
|
159
|
+
// (now shifted) assistant turn — or collapses to -1 if it falls off the front.
|
|
146
160
|
if (session.history.length > MAX_HISTORY) {
|
|
161
|
+
const dropped = session.history.length - MAX_HISTORY;
|
|
147
162
|
session.history = session.history.slice(-MAX_HISTORY);
|
|
163
|
+
if (session.lastSdkHistoryIndex >= 0) {
|
|
164
|
+
session.lastSdkHistoryIndex = Math.max(-1, session.lastSdkHistoryIndex - dropped);
|
|
165
|
+
}
|
|
148
166
|
}
|
|
149
167
|
}
|
|
150
168
|
/** Get all active sessions (for web UI session browser). */
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-Agent Delivery Router (I3) — context-aware rendering of sub-agent
|
|
3
|
+
* results into Telegram. Source decides the delivery path:
|
|
4
|
+
* - implicit → no-op (main stream already shows the Task-tool result)
|
|
5
|
+
* - user → banner+final as a new message in parentChatId
|
|
6
|
+
* - cron → banner+final in chatId from the CronJob target
|
|
7
|
+
*
|
|
8
|
+
* The caller is responsible for passing a correct `parentChatId` on the
|
|
9
|
+
* SubAgentInfo. Lookup of the bot API is lazy so we can unit-test the
|
|
10
|
+
* module with a fake bot via __setBotApiForTest.
|
|
11
|
+
*/
|
|
12
|
+
import { getVisibility } from "./subagents.js";
|
|
13
|
+
const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
|
|
14
|
+
const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
|
|
15
|
+
let injectedApi = null;
|
|
16
|
+
let runtimeApi = null;
|
|
17
|
+
/** Test-only hook for injecting a fake bot API. Production code must NEVER call this. */
|
|
18
|
+
export function __setBotApiForTest(api) {
|
|
19
|
+
injectedApi = api;
|
|
20
|
+
}
|
|
21
|
+
/** Wire the grammy bot API once at startup (called from src/index.ts). */
|
|
22
|
+
export function attachBotApi(api) {
|
|
23
|
+
runtimeApi = api;
|
|
24
|
+
}
|
|
25
|
+
function getBotApi() {
|
|
26
|
+
return injectedApi ?? runtimeApi;
|
|
27
|
+
}
|
|
28
|
+
function formatTokens(n) {
|
|
29
|
+
if (n < 1000)
|
|
30
|
+
return `${n}`;
|
|
31
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
32
|
+
}
|
|
33
|
+
function formatDuration(ms) {
|
|
34
|
+
const s = Math.floor(ms / 1000);
|
|
35
|
+
if (s < 60)
|
|
36
|
+
return `${s}s`;
|
|
37
|
+
const m = Math.floor(s / 60);
|
|
38
|
+
const rem = s - m * 60;
|
|
39
|
+
return `${m}m ${rem}s`;
|
|
40
|
+
}
|
|
41
|
+
function statusIcon(status) {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case "completed": return "✅";
|
|
44
|
+
case "timeout": return "⏱️";
|
|
45
|
+
case "cancelled": return "⚠️";
|
|
46
|
+
case "error": return "❌";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function buildBanner(info, result) {
|
|
50
|
+
const icon = statusIcon(result.status);
|
|
51
|
+
const dur = formatDuration(result.duration);
|
|
52
|
+
const ti = formatTokens(result.tokensUsed.input);
|
|
53
|
+
const to = formatTokens(result.tokensUsed.output);
|
|
54
|
+
return `${icon} *${info.name}* ${result.status} · ${dur} · ${ti} in / ${to} out`;
|
|
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 ───────────────────────────────
|
|
207
|
+
/**
|
|
208
|
+
* Main delivery entry point. Resolves the effective visibility (override →
|
|
209
|
+
* config default), then dispatches to the source-specific renderer.
|
|
210
|
+
*
|
|
211
|
+
* Errors are logged but never thrown — delivery must not break the sub-agent
|
|
212
|
+
* lifecycle. A failed Telegram send falls through silently.
|
|
213
|
+
*/
|
|
214
|
+
export async function deliverSubAgentResult(info, result, opts = {}) {
|
|
215
|
+
// Implicit spawns: the Task-tool bridge in the main stream has already
|
|
216
|
+
// surfaced the output; extra delivery would be duplication.
|
|
217
|
+
if (info.source === "implicit")
|
|
218
|
+
return;
|
|
219
|
+
const effective = opts.visibility ?? getVisibility();
|
|
220
|
+
if (effective === "silent")
|
|
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.
|
|
226
|
+
const api = getBotApi();
|
|
227
|
+
if (!api) {
|
|
228
|
+
console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!info.parentChatId) {
|
|
232
|
+
console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const banner = buildBanner(info, result);
|
|
236
|
+
const body = result.output?.trim() || `(empty output)`;
|
|
237
|
+
try {
|
|
238
|
+
// Case 1: very long output → file upload with a short banner
|
|
239
|
+
if (body.length > FILE_UPLOAD_THRESHOLD) {
|
|
240
|
+
await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
|
|
241
|
+
try {
|
|
242
|
+
const { InputFile } = await import("grammy");
|
|
243
|
+
const buf = Buffer.from(body, "utf-8");
|
|
244
|
+
await api.sendDocument(info.parentChatId, new InputFile(buf, `${info.name}.md`));
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
console.error(`[subagent-delivery] file upload failed:`, err);
|
|
248
|
+
await api.sendMessage(info.parentChatId, body.slice(0, MAX_TG_CHUNK));
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Case 2: fits in a single message → banner + body joined
|
|
253
|
+
if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
|
|
254
|
+
await api.sendMessage(info.parentChatId, `${banner}\n\n${body}`, { parse_mode: "Markdown" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Case 3: medium output → banner as its own message, body chunked
|
|
258
|
+
await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
|
|
259
|
+
for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
|
|
260
|
+
await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -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
|
+
}
|