talon-agent 3.7.0 → 3.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +2 -2
- package/prompts/README.md +10 -2
- package/prompts/dream.md +5 -3
- package/prompts/heartbeat.md +1 -1
- package/prompts/identity.md +1 -1
- package/prompts/mem0.md +7 -5
- package/prompts/mempalace.md +7 -5
- package/prompts/system/memory-recall.md +49 -0
- package/prompts/system/workspace.md +2 -2
- package/src/backend/claude-sdk/one-shot.ts +31 -1
- package/src/backend/codex/effort.ts +30 -0
- package/src/backend/codex/handler/message.ts +12 -22
- package/src/backend/codex/one-shot.ts +20 -0
- package/src/backend/opencode/handler/message.ts +7 -13
- package/src/backend/remote-server/one-shot.ts +6 -0
- package/src/backend/shared/handler-to-events.ts +0 -1
- package/src/backend/shared/handler-types.ts +0 -8
- package/src/backend/shared/index.ts +1 -6
- package/src/backend/shared/prompt-format.ts +0 -69
- package/src/bootstrap.ts +2 -0
- package/src/core/agent-runtime/capabilities.ts +5 -49
- package/src/core/background/dream.ts +27 -2
- package/src/core/background/effort.ts +80 -0
- package/src/core/background/heartbeat/agent.ts +22 -1
- package/src/core/background/heartbeat/state.ts +7 -0
- package/src/core/engine/model-audit.ts +69 -2
- package/src/core/errors.ts +107 -3
- package/src/core/models/reasoning-levels.ts +14 -3
- package/src/core/prompt/assemble.ts +8 -5
- package/src/core/prompt/embedded-prompts.ts +16 -14
- package/src/core/types.ts +10 -0
- package/src/core/weaver/index.ts +0 -1
- package/src/core/weaver/weaver.ts +1 -24
- package/src/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/callbacks/metrics.ts +36 -0
- package/src/frontend/telegram/commands/admin.ts +9 -13
- package/src/frontend/telegram/commands/info.ts +3 -44
- package/src/frontend/telegram/helpers/diagnostics.ts +190 -70
- package/src/util/config.ts +34 -19
- package/src/core/memory/retrieval.ts +0 -92
- package/src/core/weaver/memory-prefetch.ts +0 -65
|
@@ -26,13 +26,14 @@ import asset12 from "../../../prompts/system/cron.md" with { type: "file" };
|
|
|
26
26
|
import asset13 from "../../../prompts/system/daily-memory.md" with { type: "file" };
|
|
27
27
|
import asset14 from "../../../prompts/system/goals.md" with { type: "file" };
|
|
28
28
|
import asset15 from "../../../prompts/system/heartbeat-agent.md" with { type: "file" };
|
|
29
|
-
import asset16 from "../../../prompts/system/
|
|
30
|
-
import asset17 from "../../../prompts/system/
|
|
31
|
-
import asset18 from "../../../prompts/system/
|
|
32
|
-
import asset19 from "../../../prompts/system/
|
|
33
|
-
import asset20 from "../../../prompts/
|
|
34
|
-
import asset21 from "../../../prompts/
|
|
35
|
-
import asset22 from "../../../prompts/
|
|
29
|
+
import asset16 from "../../../prompts/system/memory-recall.md" with { type: "file" };
|
|
30
|
+
import asset17 from "../../../prompts/system/persistent-memory.md" with { type: "file" };
|
|
31
|
+
import asset18 from "../../../prompts/system/skills.md" with { type: "file" };
|
|
32
|
+
import asset19 from "../../../prompts/system/triggers.md" with { type: "file" };
|
|
33
|
+
import asset20 from "../../../prompts/system/workspace.md" with { type: "file" };
|
|
34
|
+
import asset21 from "../../../prompts/teams.md" with { type: "file" };
|
|
35
|
+
import asset22 from "../../../prompts/telegram.md" with { type: "file" };
|
|
36
|
+
import asset23 from "../../../prompts/terminal.md" with { type: "file" };
|
|
36
37
|
|
|
37
38
|
/** rel path (posix, under prompts/) → embedded file path (/$bunfs/… when compiled). */
|
|
38
39
|
const ASSETS: Record<string, string> = {
|
|
@@ -52,13 +53,14 @@ const ASSETS: Record<string, string> = {
|
|
|
52
53
|
"system/daily-memory.md": asset13,
|
|
53
54
|
"system/goals.md": asset14,
|
|
54
55
|
"system/heartbeat-agent.md": asset15,
|
|
55
|
-
"system/
|
|
56
|
-
"system/
|
|
57
|
-
"system/
|
|
58
|
-
"system/
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
56
|
+
"system/memory-recall.md": asset16,
|
|
57
|
+
"system/persistent-memory.md": asset17,
|
|
58
|
+
"system/skills.md": asset18,
|
|
59
|
+
"system/triggers.md": asset19,
|
|
60
|
+
"system/workspace.md": asset20,
|
|
61
|
+
"teams.md": asset21,
|
|
62
|
+
"telegram.md": asset22,
|
|
63
|
+
"terminal.md": asset23,
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
/** Read an embedded prompt by its rel path (e.g. "system/cron.md"). */
|
package/src/core/types.ts
CHANGED
|
@@ -141,6 +141,16 @@ export type OneShotAgentParams = {
|
|
|
141
141
|
workspace: string;
|
|
142
142
|
/** Model id (interpretation is backend-specific). */
|
|
143
143
|
model: string;
|
|
144
|
+
/**
|
|
145
|
+
* Reasoning effort for the run (config `heartbeatEffort` / `dreamEffort`).
|
|
146
|
+
* Undefined = let the backend/model pick its own default, which is what
|
|
147
|
+
* every background run did before the knob existed.
|
|
148
|
+
*
|
|
149
|
+
* Honoured by the backends that expose a reasoning knob (Claude SDK
|
|
150
|
+
* thinking/effort, Codex `modelReasoningEffort`); ignored by the ones
|
|
151
|
+
* that don't (Kilo, OpenCode).
|
|
152
|
+
*/
|
|
153
|
+
reasoningEffort?: ReasoningEffortLevel;
|
|
144
154
|
/**
|
|
145
155
|
* Sentinel chat ID for outbound MCP tool calls (e.g. "heartbeat", "dream").
|
|
146
156
|
* Frontend MCP servers use this to enforce explicit `chat_id` on outbound
|
package/src/core/weaver/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ export { ThreadSession, type SessionSummary } from "./thread-session.js";
|
|
|
3
3
|
export { Loom, type ContextRegistry } from "./loom.js";
|
|
4
4
|
export { carryTurnEvents, type EventSink } from "./shuttle.js";
|
|
5
5
|
export { startTypingLoop, TYPING_REFRESH_MS } from "./typing-loop.js";
|
|
6
|
-
export { prefetchMemory } from "./memory-prefetch.js";
|
|
7
6
|
export {
|
|
8
7
|
resolveWarp,
|
|
9
8
|
type WarpResolution,
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
* null-model guard and per-run override fallback;
|
|
8
8
|
* - `startTypingLoop` (typing-loop.ts) — keeps the frontend's typing
|
|
9
9
|
* indicator alive for the duration of the turn;
|
|
10
|
-
* - `prefetchMemory` (memory-prefetch.ts) — optional fail-closed
|
|
11
|
-
* palace pre-retrieval for live user messages;
|
|
12
10
|
* - `carryTurnEvents` (shuttle.ts) — pumps the backend's AgentEvent
|
|
13
11
|
* stream into the frontend sink, settles delivery acks, captures
|
|
14
12
|
* the result and rethrows error terminators.
|
|
@@ -22,13 +20,11 @@ import { randomBytes } from "node:crypto";
|
|
|
22
20
|
import type { Backend } from "../agent-runtime/capabilities.js";
|
|
23
21
|
import type { AgentResult } from "../agent-runtime/events.js";
|
|
24
22
|
import type { ModelRef } from "../agent-runtime/model-ref.js";
|
|
25
|
-
import type { MemoryRetriever } from "../memory/retrieval.js";
|
|
26
23
|
import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
|
|
27
24
|
import { bus } from "../bus/index.js";
|
|
28
25
|
import { taskTable, type TaskHandle } from "../tasks/index.js";
|
|
29
26
|
import { log, logDebug, logWarn } from "../../util/log.js";
|
|
30
27
|
import { Loom } from "./loom.js";
|
|
31
|
-
import { prefetchMemory } from "./memory-prefetch.js";
|
|
32
28
|
import { carryTurnEvents } from "./shuttle.js";
|
|
33
29
|
import type { Thread, ThreadSnapshot } from "./thread.js";
|
|
34
30
|
import { startTypingLoop } from "./typing-loop.js";
|
|
@@ -52,13 +48,6 @@ export type WeaverDeps = {
|
|
|
52
48
|
) => Promise<ModelRef | null>;
|
|
53
49
|
context: ContextManager;
|
|
54
50
|
sendTyping: (chatId: number, stringId?: string) => Promise<void>;
|
|
55
|
-
/**
|
|
56
|
-
* Optional memory pre-retrieval (Phase B). Called for `source: "message"`
|
|
57
|
-
* turns after model/backend resolution and context acquisition, before
|
|
58
|
-
* `runChatTurn(...)`. Fail-closed: errors are logged and the turn runs
|
|
59
|
-
* without injected memory. Absent dep ⇒ prompts byte-identical to before.
|
|
60
|
-
*/
|
|
61
|
-
retrieveMemory?: MemoryRetriever;
|
|
62
51
|
};
|
|
63
52
|
|
|
64
53
|
export class Weaver {
|
|
@@ -163,7 +152,7 @@ export class Weaver {
|
|
|
163
152
|
params: ExecuteParams,
|
|
164
153
|
task: TaskHandle,
|
|
165
154
|
): Promise<ExecuteResult> {
|
|
166
|
-
const { context
|
|
155
|
+
const { context } = this.deps;
|
|
167
156
|
const backend = this.deps.getBackend(params.chatId);
|
|
168
157
|
const reqId = randomBytes(4).toString("hex");
|
|
169
158
|
|
|
@@ -237,17 +226,6 @@ export class Weaver {
|
|
|
237
226
|
);
|
|
238
227
|
}
|
|
239
228
|
|
|
240
|
-
const retrievedMemory =
|
|
241
|
-
retrieveMemory && params.source === "message"
|
|
242
|
-
? await prefetchMemory(retrieveMemory, {
|
|
243
|
-
chatId: params.chatId,
|
|
244
|
-
text: params.prompt,
|
|
245
|
-
senderName: params.senderName,
|
|
246
|
-
isGroup: params.isGroup,
|
|
247
|
-
reqId,
|
|
248
|
-
})
|
|
249
|
-
: undefined;
|
|
250
|
-
|
|
251
229
|
const stream = backend.chat.runChatTurn({
|
|
252
230
|
chatId: params.chatId,
|
|
253
231
|
model: warp.ref,
|
|
@@ -255,7 +233,6 @@ export class Weaver {
|
|
|
255
233
|
senderName: params.senderName,
|
|
256
234
|
isGroup: params.isGroup,
|
|
257
235
|
messageId: params.messageId,
|
|
258
|
-
retrievedMemory,
|
|
259
236
|
});
|
|
260
237
|
const agentResult = await carryTurnEvents(stream, params.onEvent);
|
|
261
238
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - `settings` — `settings:*` (effort/proactive + stale-picker handling)
|
|
8
8
|
* - `pulse` — `pulse:*`
|
|
9
9
|
* - `effort` — `effort:*`
|
|
10
|
+
* - `metrics` — `metrics:*` (today ↔ all-time panel grain)
|
|
10
11
|
* - `model` — `model:*` (menu / backend / browse controller)
|
|
11
12
|
*
|
|
12
13
|
* `registerCallbacks` installs one `callback_query:data` listener that
|
|
@@ -22,6 +23,7 @@ import type { CallbackDeps } from "./shared.js";
|
|
|
22
23
|
import { handleSettingsCallback } from "./settings.js";
|
|
23
24
|
import { handlePulseCallback } from "./pulse.js";
|
|
24
25
|
import { handleEffortCallback } from "./effort.js";
|
|
26
|
+
import { handleMetricsCallback } from "./metrics.js";
|
|
25
27
|
import { handleModelCallback } from "./model.js";
|
|
26
28
|
|
|
27
29
|
export { answerCallbackQuerySafe } from "./shared.js";
|
|
@@ -56,6 +58,12 @@ export function registerCallbacks(
|
|
|
56
58
|
return;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Handle /metrics grain switching (today ↔ all time).
|
|
62
|
+
if (data.startsWith("metrics:")) {
|
|
63
|
+
await handleMetricsCallback(ctx, data);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
// Handle /model callbacks via the pure parser + menu controller.
|
|
60
68
|
if (data.startsWith("model:")) {
|
|
61
69
|
await handleModelCallback(ctx, data, cid, deps);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `metrics:*` callbacks — swap the /metrics panel between the today and
|
|
3
|
+
* all-time grains by editing the message in place.
|
|
4
|
+
*
|
|
5
|
+
* Admin-gated like the command that posts the panel: in a group chat the
|
|
6
|
+
* buttons sit on a message anyone can tap.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Context } from "grammy";
|
|
10
|
+
import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
|
|
11
|
+
import {
|
|
12
|
+
renderMetricsKeyboard,
|
|
13
|
+
renderMetricsPanel,
|
|
14
|
+
type MetricsView,
|
|
15
|
+
} from "../helpers/index.js";
|
|
16
|
+
import { isAuthorizedAdmin } from "../commands/state.js";
|
|
17
|
+
import { answerCallbackQuerySafe, editOrIgnoreSame } from "./shared.js";
|
|
18
|
+
|
|
19
|
+
export async function handleMetricsCallback(
|
|
20
|
+
ctx: Context,
|
|
21
|
+
data: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (!isAuthorizedAdmin(ctx)) {
|
|
24
|
+
await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const view: MetricsView = data === "metrics:all" ? "all" : "today";
|
|
29
|
+
await answerCallbackQuerySafe(ctx);
|
|
30
|
+
const metrics = view === "all" ? getMetrics() : getTodayMetrics();
|
|
31
|
+
await editOrIgnoreSame(
|
|
32
|
+
ctx,
|
|
33
|
+
renderMetricsPanel(metrics, view),
|
|
34
|
+
renderMetricsKeyboard(view),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -19,11 +19,12 @@ import { closestMatch } from "../../../native/strsim.js";
|
|
|
19
19
|
import {
|
|
20
20
|
formatDuration,
|
|
21
21
|
renderDoctorMessage,
|
|
22
|
-
|
|
22
|
+
renderMetricsKeyboard,
|
|
23
|
+
renderMetricsPanel,
|
|
23
24
|
} from "../helpers/index.js";
|
|
24
25
|
import { collectDoctorReport } from "../../../core/doctor.js";
|
|
25
26
|
import { handleAdminCommand } from "../admin.js";
|
|
26
|
-
import {
|
|
27
|
+
import { getTodayMetrics } from "../../../util/metrics.js";
|
|
27
28
|
import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
|
|
28
29
|
import { telegramCommandMenu } from "./definitions.js";
|
|
29
30
|
|
|
@@ -44,17 +45,12 @@ export function registerAdminCommands(
|
|
|
44
45
|
await ctx.reply("Not authorized.");
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
),
|
|
54
|
-
];
|
|
55
|
-
for (const message of messages) {
|
|
56
|
-
await ctx.reply(message, { parse_mode: "HTML" });
|
|
57
|
-
}
|
|
48
|
+
// One message, two grains. Today opens first — it is the smaller,
|
|
49
|
+
// more actionable view; All time is a tap away on the same message.
|
|
50
|
+
await ctx.reply(renderMetricsPanel(getTodayMetrics(), "today"), {
|
|
51
|
+
parse_mode: "HTML",
|
|
52
|
+
reply_markup: { inline_keyboard: renderMetricsKeyboard("today") },
|
|
53
|
+
});
|
|
58
54
|
});
|
|
59
55
|
|
|
60
56
|
bot.command("doctor", async (ctx) => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Bot } from "grammy";
|
|
6
6
|
import { isUserClientReady } from "../userbot.js";
|
|
7
7
|
import { escapeHtml } from "../formatting.js";
|
|
8
|
-
import { formatDuration } from "../helpers/index.js";
|
|
8
|
+
import { formatDuration, renderMeshReport } from "../helpers/index.js";
|
|
9
9
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
10
10
|
import { getMeshService } from "../../../core/mesh/index.js";
|
|
11
11
|
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
@@ -104,7 +104,7 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
bot.command("mesh", async (ctx) => {
|
|
107
|
-
const sent = await ctx.reply("
|
|
107
|
+
const sent = await ctx.reply("Pinging mesh devices…");
|
|
108
108
|
let results: MeshPingResult[];
|
|
109
109
|
try {
|
|
110
110
|
results = await getMeshService().pingAll();
|
|
@@ -117,33 +117,11 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
117
117
|
);
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
if (results.length === 0) {
|
|
121
|
-
await editOrReply(
|
|
122
|
-
bot,
|
|
123
|
-
ctx.chat.id,
|
|
124
|
-
sent.message_id,
|
|
125
|
-
"<b>🛰️ Mesh</b>\n\nNo devices have registered yet.",
|
|
126
|
-
);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
// Online (reachable first, by latency) before offline; a stable, useful
|
|
130
|
-
// order for a glance at the fleet.
|
|
131
|
-
const sorted = [...results].sort((a, b) => {
|
|
132
|
-
if (a.device.online !== b.device.online) return a.device.online ? -1 : 1;
|
|
133
|
-
return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
|
|
134
|
-
});
|
|
135
|
-
const online = results.filter((r) => r.device.online).length;
|
|
136
|
-
const reachable = results.filter((r) => r.reachable).length;
|
|
137
|
-
const lines = sorted.map((r) => meshLine(r));
|
|
138
120
|
await editOrReply(
|
|
139
121
|
bot,
|
|
140
122
|
ctx.chat.id,
|
|
141
123
|
sent.message_id,
|
|
142
|
-
|
|
143
|
-
`<b>🛰️ Mesh</b> — ${results.length} device(s), ${online} online, ${reachable} responding`,
|
|
144
|
-
"",
|
|
145
|
-
...lines,
|
|
146
|
-
].join("\n"),
|
|
124
|
+
renderMeshReport(results),
|
|
147
125
|
);
|
|
148
126
|
});
|
|
149
127
|
|
|
@@ -171,25 +149,6 @@ export function registerInfoCommands(bot: Bot): void {
|
|
|
171
149
|
});
|
|
172
150
|
}
|
|
173
151
|
|
|
174
|
-
/** One `/mesh` line: presence + reachability + latency + platform. */
|
|
175
|
-
function meshLine(r: MeshPingResult): string {
|
|
176
|
-
const d = r.device;
|
|
177
|
-
const dot = r.reachable ? "🟢" : d.online ? "🟡" : "⚪";
|
|
178
|
-
const name = `<b>${escapeHtml(d.name)}</b>`;
|
|
179
|
-
const bits: string[] = [`${d.platform}`];
|
|
180
|
-
if (r.reachable && typeof r.latencyMs === "number") {
|
|
181
|
-
bits.push(`${r.latencyMs}ms`);
|
|
182
|
-
} else if (d.online && r.error) {
|
|
183
|
-
bits.push(escapeHtml(r.error));
|
|
184
|
-
} else if (!d.online) {
|
|
185
|
-
bits.push(`offline, last seen ${formatDuration(Date.now() - d.lastSeen)}`);
|
|
186
|
-
}
|
|
187
|
-
if (typeof d.battery === "number") {
|
|
188
|
-
bits.push(`${d.battery}%${d.charging ? "⚡" : ""}`);
|
|
189
|
-
}
|
|
190
|
-
return `${dot} ${name} — ${bits.join(" · ")}`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
152
|
/** Edit the placeholder in place, falling back to a fresh reply. */
|
|
194
153
|
async function editOrReply(
|
|
195
154
|
bot: Bot,
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Metrics
|
|
2
|
+
* Metrics, doctor, and mesh report rendering for Telegram (HTML messages).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { escapeHtml } from "../formatting.js";
|
|
6
6
|
import type { DoctorReport } from "../../../core/doctor.js";
|
|
7
|
+
import type { MeshPingResult } from "../../../core/mesh/service.js";
|
|
8
|
+
import type { SettingsButton } from "./menu.js";
|
|
7
9
|
import { formatDuration, formatBytes } from "./format.js";
|
|
8
10
|
|
|
9
11
|
const DEFAULT_METRICS_MESSAGE_MAX = 3800;
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Rows shown in the `tool_calls` leaderboard before the tail collapses
|
|
15
|
+
* into a "…and N more" line. A long-lived bot accumulates a lifetime
|
|
16
|
+
* tool-call entry per distinct tool name (easily 100+ once plugin and
|
|
17
|
+
* MCP tools are counted), which is what used to push /metrics past
|
|
18
|
+
* Telegram's message limit and split it across messages.
|
|
19
|
+
*/
|
|
20
|
+
const TOOL_CALLS_TOP_N = 12;
|
|
21
|
+
|
|
11
22
|
type MetricsSnapshot = {
|
|
12
23
|
counters: Record<string, number>;
|
|
13
24
|
histograms: Record<
|
|
@@ -16,18 +27,26 @@ type MetricsSnapshot = {
|
|
|
16
27
|
>;
|
|
17
28
|
};
|
|
18
29
|
|
|
30
|
+
/** Which grain the /metrics panel is currently showing. */
|
|
31
|
+
export type MetricsView = "today" | "all";
|
|
32
|
+
|
|
33
|
+
const VIEW_TITLES: Record<MetricsView, string> = {
|
|
34
|
+
today: "Metrics — today (UTC)",
|
|
35
|
+
all: "Metrics — all time",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One titled block of the panel. `hidden` counts rows dropped to make
|
|
40
|
+
* the panel fit one message; it renders as a trailing "…and N more".
|
|
41
|
+
*/
|
|
42
|
+
type Section = { title: string; rows: string[]; hidden: number };
|
|
43
|
+
|
|
19
44
|
function truncateMetricLabel(label: string, max = 80): string {
|
|
20
45
|
return label.length <= max ? label : `${label.slice(0, max - 3)}...`;
|
|
21
46
|
}
|
|
22
47
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
maxLen = DEFAULT_METRICS_MESSAGE_MAX,
|
|
26
|
-
title = "📊 Metrics",
|
|
27
|
-
): string[] {
|
|
28
|
-
const firstHeader = `<b>${escapeHtml(title)}</b>`;
|
|
29
|
-
const continuationHeader = `<b>${escapeHtml(title)} (cont.)</b>`;
|
|
30
|
-
const sections: string[][] = [];
|
|
48
|
+
function buildMetricsSections(metrics: MetricsSnapshot): Section[] {
|
|
49
|
+
const sections: Section[] = [];
|
|
31
50
|
|
|
32
51
|
// Histograms come in two flavours: durations (keys ending in `_ms`,
|
|
33
52
|
// rendered as human times) and plain counts like `tool_calls_per_turn`
|
|
@@ -44,16 +63,18 @@ export function renderMetricsMessages(
|
|
|
44
63
|
);
|
|
45
64
|
};
|
|
46
65
|
if (durationKeys.length > 0) {
|
|
47
|
-
sections.push(
|
|
48
|
-
"<b>Latency</b>",
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
sections.push({
|
|
67
|
+
title: "<b>Latency</b>",
|
|
68
|
+
rows: durationKeys.map((key) => histLine(key, formatDuration)),
|
|
69
|
+
hidden: 0,
|
|
70
|
+
});
|
|
51
71
|
}
|
|
52
72
|
if (countKeys.length > 0) {
|
|
53
|
-
sections.push(
|
|
54
|
-
"<b>Distributions</b>",
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
sections.push({
|
|
74
|
+
title: "<b>Distributions</b>",
|
|
75
|
+
rows: countKeys.map((key) => histLine(key, String)),
|
|
76
|
+
hidden: 0,
|
|
77
|
+
});
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
const counterKeys = Object.keys(metrics.counters).sort();
|
|
@@ -68,17 +89,20 @@ export function renderMetricsMessages(
|
|
|
68
89
|
for (const prefix of [...groups.keys()].sort()) {
|
|
69
90
|
// tool_calls reads best as a leaderboard — busiest tools first.
|
|
70
91
|
// Other groups keep alphabetical order (stable lookup by name).
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
const isToolCalls = prefix === "tool_calls";
|
|
93
|
+
const keys = isToolCalls
|
|
94
|
+
? [...groups.get(prefix)!].sort(
|
|
95
|
+
(a, b) =>
|
|
96
|
+
metrics.counters[b]! - metrics.counters[a]! || a.localeCompare(b),
|
|
97
|
+
)
|
|
98
|
+
: groups.get(prefix)!;
|
|
99
|
+
// The leaderboard is pre-capped: past the top N the tail is a long
|
|
100
|
+
// list of one-call tools nobody reads, and on the all-time view it
|
|
101
|
+
// is what made the panel overflow.
|
|
102
|
+
const shown = isToolCalls ? keys.slice(0, TOOL_CALLS_TOP_N) : keys;
|
|
103
|
+
sections.push({
|
|
104
|
+
title: `<b>${escapeHtml(prefix)}</b>`,
|
|
105
|
+
rows: shown.map((key) => {
|
|
82
106
|
const label = key.includes(".")
|
|
83
107
|
? key.split(".").slice(1).join(".")
|
|
84
108
|
: key;
|
|
@@ -87,59 +111,88 @@ export function renderMetricsMessages(
|
|
|
87
111
|
`${metrics.counters[key]!.toLocaleString()}`
|
|
88
112
|
);
|
|
89
113
|
}),
|
|
90
|
-
|
|
114
|
+
hidden: keys.length - shown.length,
|
|
115
|
+
});
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
118
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const chunks: string[] = [];
|
|
99
|
-
let header = firstHeader;
|
|
100
|
-
let current = header;
|
|
101
|
-
|
|
102
|
-
const flush = () => {
|
|
103
|
-
chunks.push(current);
|
|
104
|
-
header = continuationHeader;
|
|
105
|
-
current = header;
|
|
106
|
-
};
|
|
119
|
+
return sections;
|
|
120
|
+
}
|
|
107
121
|
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
function renderSection(section: Section): string {
|
|
123
|
+
const lines = [section.title, ...section.rows];
|
|
124
|
+
if (section.hidden > 0) {
|
|
125
|
+
lines.push(` <i>…and ${section.hidden} more</i>`);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
110
129
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Render the sections under `header`, shrinking to fit `maxLen`.
|
|
132
|
+
*
|
|
133
|
+
* Telegram hard-caps a message at 4096 characters. Rather than split the
|
|
134
|
+
* report across messages — which is what the panel replaced — rows are
|
|
135
|
+
* dropped from the longest section first (each drop bumping that
|
|
136
|
+
* section's "…and N more") until the whole thing fits. Sections always
|
|
137
|
+
* keep at least their first row, so every group stays visible.
|
|
138
|
+
*/
|
|
139
|
+
function fitPanel(header: string, sections: Section[], maxLen: number): string {
|
|
140
|
+
const render = () => [header, ...sections.map(renderSection)].join("\n\n");
|
|
116
141
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
let out = render();
|
|
143
|
+
while (out.length > maxLen) {
|
|
144
|
+
let target: Section | undefined;
|
|
145
|
+
for (const section of sections) {
|
|
146
|
+
if (section.rows.length <= 1) continue;
|
|
147
|
+
if (!target || section.rows.length > target.rows.length) target = section;
|
|
120
148
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
line.length <= available
|
|
126
|
-
? line
|
|
127
|
-
: available >= 4
|
|
128
|
-
? `${line.slice(0, available - 3)}...`
|
|
129
|
-
: line.slice(0, available); // not enough room for ellipsis — just truncate
|
|
130
|
-
current = `${current}\n${safeLine}`;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
for (const section of sections) {
|
|
134
|
-
appendLine("");
|
|
135
|
-
for (const line of section) appendLine(line);
|
|
149
|
+
if (!target) break;
|
|
150
|
+
target.rows.pop();
|
|
151
|
+
target.hidden += 1;
|
|
152
|
+
out = render();
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
if (
|
|
139
|
-
|
|
155
|
+
if (out.length <= maxLen) return out;
|
|
156
|
+
// Nothing left to shed (a pathologically small maxLen). Cut on a line
|
|
157
|
+
// boundary so we never slice through an HTML tag and fail the parse.
|
|
158
|
+
const cut = out.lastIndexOf("\n", maxLen - 1);
|
|
159
|
+
return cut > 0 ? out.slice(0, cut) : out.slice(0, maxLen);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render the /metrics panel for one grain as a SINGLE Telegram message.
|
|
164
|
+
* Pair it with `renderMetricsKeyboard(view)` — the Today / All time
|
|
165
|
+
* buttons swap grains by editing this message in place.
|
|
166
|
+
*/
|
|
167
|
+
export function renderMetricsPanel(
|
|
168
|
+
metrics: MetricsSnapshot,
|
|
169
|
+
view: MetricsView,
|
|
170
|
+
maxLen = DEFAULT_METRICS_MESSAGE_MAX,
|
|
171
|
+
): string {
|
|
172
|
+
const header = `<b>${escapeHtml(VIEW_TITLES[view])}</b>`;
|
|
173
|
+
const sections = buildMetricsSections(metrics);
|
|
174
|
+
if (sections.length === 0) {
|
|
175
|
+
return `${header}\n\n<i>No metrics recorded yet.</i>`;
|
|
140
176
|
}
|
|
177
|
+
return fitPanel(header, sections, maxLen);
|
|
178
|
+
}
|
|
141
179
|
|
|
142
|
-
|
|
180
|
+
/** Grain-switch buttons for the /metrics panel. */
|
|
181
|
+
export function renderMetricsKeyboard(
|
|
182
|
+
view: MetricsView,
|
|
183
|
+
): Array<Array<SettingsButton>> {
|
|
184
|
+
return [
|
|
185
|
+
[
|
|
186
|
+
{
|
|
187
|
+
text: view === "today" ? "✓ Today" : "Today",
|
|
188
|
+
callback_data: "metrics:today",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
text: view === "all" ? "✓ All time" : "All time",
|
|
192
|
+
callback_data: "metrics:all",
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
];
|
|
143
196
|
}
|
|
144
197
|
|
|
145
198
|
const DOCTOR_ICONS: Record<string, string> = {
|
|
@@ -187,3 +240,70 @@ export function renderDoctorMessage(report: DoctorReport): string {
|
|
|
187
240
|
|
|
188
241
|
return lines.join("\n");
|
|
189
242
|
}
|
|
243
|
+
|
|
244
|
+
// ── /mesh ───────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
function meshDeviceLine(r: MeshPingResult, now: number): string {
|
|
247
|
+
const d = r.device;
|
|
248
|
+
const bits: string[] = [escapeHtml(d.platform)];
|
|
249
|
+
if (r.reachable && typeof r.latencyMs === "number") {
|
|
250
|
+
bits.push(`${r.latencyMs} ms`);
|
|
251
|
+
} else if (d.online && r.error) {
|
|
252
|
+
bits.push(escapeHtml(r.error));
|
|
253
|
+
} else if (!d.online) {
|
|
254
|
+
bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof d.battery === "number") {
|
|
257
|
+
bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
|
|
258
|
+
}
|
|
259
|
+
return ` <b>${escapeHtml(d.name)}</b> — ${bits.join(" · ")}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the `/mesh` fleet report as one HTML message.
|
|
264
|
+
*
|
|
265
|
+
* Devices group under a state heading — Responding, Unreachable, Offline
|
|
266
|
+
* — rather than carrying a coloured status glyph per row: the grouping
|
|
267
|
+
* already says what the glyph said, and the report stays readable when
|
|
268
|
+
* the fleet grows. Empty groups are omitted entirely.
|
|
269
|
+
*/
|
|
270
|
+
export function renderMeshReport(
|
|
271
|
+
results: MeshPingResult[],
|
|
272
|
+
now = Date.now(),
|
|
273
|
+
): string {
|
|
274
|
+
if (results.length === 0) {
|
|
275
|
+
return "<b>Mesh</b>\n\n<i>No devices have registered yet.</i>";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const responding = results
|
|
279
|
+
.filter((r) => r.reachable)
|
|
280
|
+
.sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
|
|
281
|
+
const unreachable = results
|
|
282
|
+
.filter((r) => !r.reachable && r.device.online)
|
|
283
|
+
.sort((a, b) => a.device.name.localeCompare(b.device.name));
|
|
284
|
+
const offline = results
|
|
285
|
+
.filter((r) => !r.reachable && !r.device.online)
|
|
286
|
+
.sort((a, b) => b.device.lastSeen - a.device.lastSeen);
|
|
287
|
+
|
|
288
|
+
const summary = [
|
|
289
|
+
`${results.length} device${results.length === 1 ? "" : "s"}`,
|
|
290
|
+
`${responding.length} responding`,
|
|
291
|
+
...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
|
|
292
|
+
...(offline.length > 0 ? [`${offline.length} offline`] : []),
|
|
293
|
+
].join(" · ");
|
|
294
|
+
|
|
295
|
+
const lines = ["<b>Mesh</b>", summary];
|
|
296
|
+
const section = (title: string, entries: MeshPingResult[]): void => {
|
|
297
|
+
if (entries.length === 0) return;
|
|
298
|
+
lines.push(
|
|
299
|
+
"",
|
|
300
|
+
`<b>${title}</b>`,
|
|
301
|
+
...entries.map((r) => meshDeviceLine(r, now)),
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
section("Responding", responding);
|
|
305
|
+
section("Unreachable", unreachable);
|
|
306
|
+
section("Offline", offline);
|
|
307
|
+
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|