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.
Files changed (42) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -2
  3. package/prompts/README.md +10 -2
  4. package/prompts/dream.md +5 -3
  5. package/prompts/heartbeat.md +1 -1
  6. package/prompts/identity.md +1 -1
  7. package/prompts/mem0.md +7 -5
  8. package/prompts/mempalace.md +7 -5
  9. package/prompts/system/memory-recall.md +49 -0
  10. package/prompts/system/workspace.md +2 -2
  11. package/src/backend/claude-sdk/one-shot.ts +31 -1
  12. package/src/backend/codex/effort.ts +30 -0
  13. package/src/backend/codex/handler/message.ts +12 -22
  14. package/src/backend/codex/one-shot.ts +20 -0
  15. package/src/backend/opencode/handler/message.ts +7 -13
  16. package/src/backend/remote-server/one-shot.ts +6 -0
  17. package/src/backend/shared/handler-to-events.ts +0 -1
  18. package/src/backend/shared/handler-types.ts +0 -8
  19. package/src/backend/shared/index.ts +1 -6
  20. package/src/backend/shared/prompt-format.ts +0 -69
  21. package/src/bootstrap.ts +2 -0
  22. package/src/core/agent-runtime/capabilities.ts +5 -49
  23. package/src/core/background/dream.ts +27 -2
  24. package/src/core/background/effort.ts +80 -0
  25. package/src/core/background/heartbeat/agent.ts +22 -1
  26. package/src/core/background/heartbeat/state.ts +7 -0
  27. package/src/core/engine/model-audit.ts +69 -2
  28. package/src/core/errors.ts +107 -3
  29. package/src/core/models/reasoning-levels.ts +14 -3
  30. package/src/core/prompt/assemble.ts +8 -5
  31. package/src/core/prompt/embedded-prompts.ts +16 -14
  32. package/src/core/types.ts +10 -0
  33. package/src/core/weaver/index.ts +0 -1
  34. package/src/core/weaver/weaver.ts +1 -24
  35. package/src/frontend/telegram/callbacks/index.ts +8 -0
  36. package/src/frontend/telegram/callbacks/metrics.ts +36 -0
  37. package/src/frontend/telegram/commands/admin.ts +9 -13
  38. package/src/frontend/telegram/commands/info.ts +3 -44
  39. package/src/frontend/telegram/helpers/diagnostics.ts +190 -70
  40. package/src/util/config.ts +34 -19
  41. package/src/core/memory/retrieval.ts +0 -92
  42. 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/persistent-memory.md" with { type: "file" };
30
- import asset17 from "../../../prompts/system/skills.md" with { type: "file" };
31
- import asset18 from "../../../prompts/system/triggers.md" with { type: "file" };
32
- import asset19 from "../../../prompts/system/workspace.md" with { type: "file" };
33
- import asset20 from "../../../prompts/teams.md" with { type: "file" };
34
- import asset21 from "../../../prompts/telegram.md" with { type: "file" };
35
- import asset22 from "../../../prompts/terminal.md" with { type: "file" };
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/persistent-memory.md": asset16,
56
- "system/skills.md": asset17,
57
- "system/triggers.md": asset18,
58
- "system/workspace.md": asset19,
59
- "teams.md": asset20,
60
- "telegram.md": asset21,
61
- "terminal.md": asset22,
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
@@ -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, retrieveMemory } = this.deps;
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
- renderMetricsMessages,
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 { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
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
- const messages = [
48
- ...renderMetricsMessages(getMetrics()),
49
- ...renderMetricsMessages(
50
- getTodayMetrics(),
51
- undefined,
52
- "📊 Metrics — today (UTC)",
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("🛰️ Pinging mesh devices…");
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 + doctor report rendering for Telegram (HTML messages).
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
- export function renderMetricsMessages(
24
- metrics: MetricsSnapshot,
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
- ...durationKeys.map((key) => histLine(key, formatDuration)),
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
- ...countKeys.map((key) => histLine(key, String)),
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 keys =
72
- prefix === "tool_calls"
73
- ? [...groups.get(prefix)!].sort(
74
- (a, b) =>
75
- metrics.counters[b]! - metrics.counters[a]! ||
76
- a.localeCompare(b),
77
- )
78
- : groups.get(prefix)!;
79
- sections.push([
80
- `<b>${escapeHtml(prefix)}</b>`,
81
- ...keys.map((key) => {
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
- if (sections.length === 0) {
95
- return [`${firstHeader}\n\n<i>No metrics recorded yet.</i>`];
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
- const appendLine = (line: string) => {
109
- if (!line && current === header) return;
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
- const candidate = `${current}\n${line}`;
112
- if (candidate.length <= maxLen) {
113
- current = candidate;
114
- return;
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
- if (current !== header) {
118
- flush();
119
- if (!line) return;
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
- const available = maxLen - header.length - 1;
123
- if (available < 0) return; // header alone already fills maxLen — skip line
124
- const safeLine =
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 (current !== header || chunks.length === 0) {
139
- chunks.push(current);
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
- return chunks;
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
+ }