talon-agent 1.36.0 → 1.37.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.
@@ -31,6 +31,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
31
31
  import { tmpdir } from "node:os";
32
32
  import { basename, dirname, join, resolve } from "node:path";
33
33
  import type { Readable } from "node:stream";
34
+ import { clampExecOutput } from "../../util/exec-output.js";
34
35
  import { dirs } from "../../util/paths.js";
35
36
  import { MeshRegistry } from "./registry.js";
36
37
  import { TransferStore } from "./transfers.js";
@@ -87,6 +88,13 @@ const FILE_CHUNK_BYTES = 1024 * 1024;
87
88
  const STREAM_TRANSFER_TIMEOUT_MS = 60 * 60 * 1000;
88
89
  /** readFileBytes switches to the streaming path above this size. */
89
90
  const STREAM_READ_THRESHOLD_BYTES = 4 * 1024 * 1024;
91
+ /**
92
+ * Hard ceiling on a chunked (command-channel) transfer. The chunked path
93
+ * assembles the whole file in daemon memory one mesh round trip at a time —
94
+ * past this size it's both a memory hazard and unusably slow, so fail with
95
+ * a pointer to the streaming path instead of grinding on.
96
+ */
97
+ const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
90
98
  /**
91
99
  * No policy size cap on transfers — a transfer is attempted whatever the
92
100
  * size and fails with a concrete error when a real limit bites (device read
@@ -108,7 +116,10 @@ export class MeshService {
108
116
  private readonly transports = new Set<MeshTransport>();
109
117
  private readonly pendingCommands = new Map<
110
118
  string,
111
- (result: DeviceCommandResult) => void
119
+ {
120
+ deviceId: string;
121
+ resolve: (result: DeviceCommandResult) => void;
122
+ }
112
123
  >();
113
124
  /**
114
125
  * Server-side receipt time (this process's clock) of the last fix per
@@ -203,11 +214,19 @@ export class MeshService {
203
214
  * A device answered a command (bridge route POST /devices/command-result).
204
215
  * Resolves the pending sendCommand; false when nothing was waiting (late
205
216
  * or unknown correlation id — harmless, just ignored).
217
+ *
218
+ * The result must come from the device the command was sent to: a reply
219
+ * whose deviceId names a DIFFERENT device is dropped (a confused or
220
+ * misbehaving companion must not be able to answer for its peers). A
221
+ * missing deviceId is tolerated for older app builds.
206
222
  */
207
223
  completeCommand(body: Record<string, unknown>): boolean {
208
224
  const commandId = typeof body.commandId === "string" ? body.commandId : "";
209
- const resolve = this.pendingCommands.get(commandId);
210
- if (!resolve) return false;
225
+ const pending = this.pendingCommands.get(commandId);
226
+ if (!pending) return false;
227
+ const from = typeof body.deviceId === "string" ? body.deviceId : "";
228
+ if (from && from !== pending.deviceId) return false;
229
+ const { resolve } = pending;
211
230
  this.pendingCommands.delete(commandId);
212
231
  resolve({
213
232
  commandId,
@@ -253,9 +272,12 @@ export class MeshService {
253
272
  });
254
273
  }, timeoutMs);
255
274
  timer.unref?.();
256
- this.pendingCommands.set(command.id, (result) => {
257
- clearTimeout(timer);
258
- resolve(result);
275
+ this.pendingCommands.set(command.id, {
276
+ deviceId: device.id,
277
+ resolve: (result) => {
278
+ clearTimeout(timer);
279
+ resolve(result);
280
+ },
259
281
  });
260
282
  for (const transport of this.transports) {
261
283
  try {
@@ -289,8 +311,9 @@ export class MeshService {
289
311
  */
290
312
  async locateDevice(query?: unknown): Promise<MeshToolResult> {
291
313
  await this.load();
292
- const target = this.chooseDevice(query);
293
- if (!target) return this.noSuchDevice(query);
314
+ const resolved = this.resolveDevice(query);
315
+ if ("error" in resolved) return { ok: false, text: resolved.error };
316
+ const target = resolved.target;
294
317
  const requestedAt = Date.now();
295
318
  // Only wait out the fresh-fix window when a transport can actually
296
319
  // deliver the locate AND the device is currently present — pinging an
@@ -334,8 +357,9 @@ export class MeshService {
334
357
  hours?: unknown,
335
358
  ): Promise<MeshToolResult> {
336
359
  await this.load();
337
- const target = this.chooseDevice(query);
338
- if (!target) return this.noSuchDevice(query);
360
+ const resolved = this.resolveDevice(query);
361
+ if ("error" in resolved) return { ok: false, text: resolved.error };
362
+ const target = resolved.target;
339
363
  const windowHours = clampHours(hours);
340
364
  const sinceTs = Date.now() - windowHours * 3_600_000;
341
365
  const fixes = this.registry.getHistory(target.id, sinceTs);
@@ -546,8 +570,10 @@ export class MeshService {
546
570
  const p = requirePath(path);
547
571
  if (!p) return { error: "A file path is required." };
548
572
  await this.load();
549
- const target = this.chooseDevice(query);
550
- if (target && this.canStream(target, "upload_file")) {
573
+ const resolved = this.resolveDevice(query);
574
+ if ("error" in resolved) return { error: resolved.error };
575
+ const target = resolved.target;
576
+ if (this.canStream(target, "upload_file")) {
551
577
  const size = await this.statSize(target.id, p);
552
578
  if (size !== undefined && size > STREAM_READ_THRESHOLD_BYTES) {
553
579
  const tmp = join(tmpdir(), `talon-pull-${randomUUID()}-${basename(p)}`);
@@ -565,7 +591,7 @@ export class MeshService {
565
591
  }
566
592
  }
567
593
  }
568
- return this.pullBytes(query, p);
594
+ return this.pullBytes(target.id, p);
569
595
  }
570
596
 
571
597
  /** Size of a device path via the `stat` command, if the device can. */
@@ -607,8 +633,16 @@ export class MeshService {
607
633
  ): Promise<MeshToolResult> {
608
634
  const p = requirePath(path);
609
635
  if (!p) return { ok: false, text: "A file path is required." };
610
- const text = typeof content === "string" ? content : "";
611
- const written = await this.pushBytes(query, p, Buffer.from(text, "utf8"));
636
+ // A non-string body must fail, not silently truncate the target to an
637
+ // empty file (an empty string is a legitimate truncate-to-zero).
638
+ if (typeof content !== "string") {
639
+ return { ok: false, text: "File content must be a string." };
640
+ }
641
+ const written = await this.pushBytes(
642
+ query,
643
+ p,
644
+ Buffer.from(content, "utf8"),
645
+ );
612
646
  if ("error" in written) return { ok: false, text: written.error };
613
647
  return {
614
648
  ok: true,
@@ -627,8 +661,9 @@ export class MeshService {
627
661
  const remote = requirePath(remotePath);
628
662
  if (!remote) return { ok: false, text: "A remote file path is required." };
629
663
  await this.load();
630
- const target = this.chooseDevice(query);
631
- if (!target) return this.noSuchDevice(query);
664
+ const resolved = this.resolveDevice(query);
665
+ if ("error" in resolved) return { ok: false, text: resolved.error };
666
+ const target = resolved.target;
632
667
  const dest =
633
668
  typeof localPath === "string" && localPath.trim()
634
669
  ? resolve(dirs.workspace, localPath.trim())
@@ -672,8 +707,9 @@ export class MeshService {
672
707
  : "";
673
708
  if (!local) return { ok: false, text: "A local source path is required." };
674
709
  await this.load();
675
- const target = this.chooseDevice(query);
676
- if (!target) return this.noSuchDevice(query);
710
+ const resolved = this.resolveDevice(query);
711
+ if ("error" in resolved) return { ok: false, text: resolved.error };
712
+ const target = resolved.target;
677
713
  if (this.canStream(target, "download_file")) {
678
714
  const started = Date.now();
679
715
  const { token } = this.transfers.createPush(target.id, local);
@@ -725,6 +761,13 @@ export class MeshService {
725
761
  /**
726
762
  * Chunked read of a remote file into a Buffer. Loops `read_file` with
727
763
  * increasing offsets until the device reports EOF.
764
+ *
765
+ * End-of-file is the DEVICE's call (`eof: true`), never inferred from a
766
+ * short chunk: devices cap their chunk size (the companion serves at most
767
+ * 256KB per read regardless of the requested length), so a chunk shorter
768
+ * than the request is normal mid-file and treating it as EOF silently
769
+ * truncated every chunked read past the device's cap. A zero-length chunk
770
+ * without `eof` is a stuck transfer and fails loudly instead of looping.
728
771
  */
729
772
  private async pullBytes(
730
773
  query: unknown,
@@ -764,8 +807,17 @@ export class MeshService {
764
807
  const chunk = Buffer.from(b64, "base64");
765
808
  chunks.push(chunk);
766
809
  offset += chunk.length;
767
- // EOF when the device says so, or when it returns a short/empty chunk.
768
- if (result.data?.eof === true || chunk.length < FILE_CHUNK_BYTES) break;
810
+ if (result.data?.eof === true) break;
811
+ if (chunk.length === 0) {
812
+ return {
813
+ error: `${path} transfer stalled: the device returned an empty chunk without reporting end-of-file (after ${formatBytes(offset)}).`,
814
+ };
815
+ }
816
+ if (offset > MAX_CHUNKED_TRANSFER_BYTES) {
817
+ return {
818
+ error: `${path} exceeds the ${formatBytes(MAX_CHUNKED_TRANSFER_BYTES)} chunked-transfer limit (device never reported end-of-file after ${formatBytes(offset)}). Use device_pull_file with a streaming-capable companion build for large files.`,
819
+ };
820
+ }
769
821
  }
770
822
  try {
771
823
  return { data: Buffer.concat(chunks), deviceName };
@@ -853,8 +905,9 @@ export class MeshService {
853
905
  { target: DeviceInfo; result: DeviceCommandResult } | { error: string }
854
906
  > {
855
907
  await this.load();
856
- const target = this.chooseDevice(query);
857
- if (!target) return { error: this.noSuchDevice(query).text };
908
+ const resolved = this.resolveDevice(query);
909
+ if ("error" in resolved) return { error: resolved.error };
910
+ const target = resolved.target;
858
911
  // Devices advertise what they can do; an explicit list that lacks the
859
912
  // command is a clean "can't" — absent list means an older app build, so
860
913
  // attempt it and let the timeout speak.
@@ -878,17 +931,64 @@ export class MeshService {
878
931
  return { target, result };
879
932
  }
880
933
 
881
- /** Resolve a device by exact id, name fragment, or default (most recently
882
- * seen mobile device, falling back to the most recent device overall). */
934
+ /** Resolve a device by exact id, exact name, or unique name fragment;
935
+ * undefined when nothing (or more than one device) matches. */
883
936
  chooseDevice(query?: unknown): DeviceInfo | undefined {
937
+ const resolved = this.resolveDevice(query);
938
+ return "target" in resolved ? resolved.target : undefined;
939
+ }
940
+
941
+ /**
942
+ * Device resolution with a caller-facing error. Matching order: exact id,
943
+ * exact name (case-insensitive), then name fragment — with names and
944
+ * queries compared separator-insensitively ("pixel 10" finds
945
+ * "Google-Pixel10"). When several devices match, a sole ONLINE match wins
946
+ * (a stale duplicate registration must not shadow the live device);
947
+ * otherwise it's an explicit error, never a silent first pick — exec and
948
+ * file commands aimed at "pixel" must not land on whichever Pixel
949
+ * happened to register first. No query defaults to the most recently
950
+ * seen mobile device, then the most recent device overall.
951
+ */
952
+ resolveDevice(query?: unknown): { target: DeviceInfo } | { error: string } {
884
953
  const devices = this.registry.list().devices;
885
954
  if (typeof query === "string" && query.trim()) {
886
- const q = query.trim().toLowerCase();
887
- return devices.find(
888
- (d) => d.id.toLowerCase() === q || d.name.toLowerCase().includes(q),
955
+ const raw = query.trim();
956
+ const q = raw.toLowerCase();
957
+ const byId = devices.find((d) => d.id.toLowerCase() === q);
958
+ if (byId) return { target: byId };
959
+ const fold = (s: string): string =>
960
+ s.toLowerCase().replace(/[^a-z0-9]+/g, "");
961
+ const folded = fold(raw);
962
+ const pick = (
963
+ candidates: DeviceInfo[],
964
+ ): { target: DeviceInfo } | { error: string } | undefined => {
965
+ if (candidates.length === 1) return { target: candidates[0]! };
966
+ if (candidates.length > 1) {
967
+ const online = candidates.filter((d) => d.online);
968
+ if (online.length === 1) return { target: online[0]! };
969
+ return {
970
+ error: `"${raw}" matches ${candidates.length} devices: ${candidates
971
+ .map(
972
+ (d) =>
973
+ `${d.name} [id: ${d.id}, ${d.online ? "online" : "offline"}]`,
974
+ )
975
+ .join(", ")}. Use the device id or full name.`,
976
+ };
977
+ }
978
+ return undefined;
979
+ };
980
+ return (
981
+ pick(devices.filter((d) => fold(d.name) === folded)) ??
982
+ (folded
983
+ ? pick(devices.filter((d) => fold(d.name).includes(folded)))
984
+ : undefined) ?? { error: this.noSuchDevice(query).text }
889
985
  );
890
986
  }
891
- return devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
987
+ const fallback =
988
+ devices.find((d) => MOBILE_PLATFORMS.has(d.platform)) ?? devices[0];
989
+ return fallback
990
+ ? { target: fallback }
991
+ : { error: this.noSuchDevice(query).text };
892
992
  }
893
993
 
894
994
  // ── Formatting ─────────────────────────────────────────────────────────────
@@ -1120,11 +1220,16 @@ function formatExecResult(
1120
1220
  const exit = typeof d.exitCode === "number" ? d.exitCode : "?";
1121
1221
  const stdout = typeof d.stdout === "string" ? d.stdout : "";
1122
1222
  const stderr = typeof d.stderr === "string" ? d.stderr : "";
1123
- const parts = [`[${device.name}] exit ${exit}`];
1223
+ const via = typeof d.via === "string" && d.via ? ` via ${d.via}` : "";
1224
+ const parts = [`[${device.name}${via}] exit ${exit}`];
1124
1225
  if (stdout.trim())
1125
- parts.push(`--- stdout ---\n${stdout.replace(/\s+$/, "")}`);
1226
+ parts.push(
1227
+ `--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
1228
+ );
1126
1229
  if (stderr.trim())
1127
- parts.push(`--- stderr ---\n${stderr.replace(/\s+$/, "")}`);
1230
+ parts.push(
1231
+ `--- stderr ---\n${clampExecOutput(stderr.replace(/\s+$/, ""))}`,
1232
+ );
1128
1233
  if (!stdout.trim() && !stderr.trim()) parts.push("(no output)");
1129
1234
  return parts.join("\n");
1130
1235
  }
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Teleport state — the single "active node" the native tools route through.
2
+ * Teleport state — the active node each chat's native tools route through.
3
3
  *
4
4
  * When a node is active, Talon's native shell/file tools (bash/read/write/
5
5
  * edit/glob/search) execute ON that companion device via the mesh exec/fs
6
6
  * channel instead of on the daemon host — Talon acts as if it were running
7
7
  * on the phone. `teleport_back` clears it and everything runs locally again.
8
8
  *
9
- * State is a tiny JSON sidecar under the Talon root so it survives restarts
10
- * and is shared across the process (the native gateway actions read it on
11
- * every call). Deliberately global, not per-chat: teleport is an operator
12
- * mode for the whole daemon, matching how Dylan drives a single bot.
9
+ * State is a tiny JSON sidecar under the Talon root so it survives restarts.
10
+ * The sidecar is keyed by chat id: teleporting in one chat must not redirect
11
+ * native tools in another chat.
13
12
  */
14
13
 
15
14
  import { readFile } from "node:fs/promises";
@@ -32,6 +31,10 @@ export type TeleportState = {
32
31
  since: number;
33
32
  };
34
33
 
34
+ type TeleportStore = {
35
+ chats: Record<string, TeleportState>;
36
+ };
37
+
35
38
  /** Resolved lazily so a test can point it at a tmp file via env. */
36
39
  function stateFile(): string {
37
40
  return (
@@ -40,34 +43,73 @@ function stateFile(): string {
40
43
  );
41
44
  }
42
45
 
43
- let cache: TeleportState | null | undefined;
46
+ let cache: TeleportStore | undefined;
44
47
 
45
- /** The active teleport target, or null when operating locally. */
46
- export async function getTeleport(): Promise<TeleportState | null> {
48
+ function emptyStore(): TeleportStore {
49
+ return { chats: Object.create(null) as Record<string, TeleportState> };
50
+ }
51
+
52
+ function normalizeChatId(chatId: number | string): string {
53
+ return String(chatId);
54
+ }
55
+
56
+ function parseState(value: unknown): TeleportState | null {
57
+ const parsed = value as Partial<TeleportState> | null;
58
+ if (!parsed || typeof parsed.deviceId !== "string" || !parsed.deviceId) {
59
+ return null;
60
+ }
61
+ return {
62
+ deviceId: parsed.deviceId,
63
+ deviceName:
64
+ typeof parsed.deviceName === "string" && parsed.deviceName
65
+ ? parsed.deviceName
66
+ : parsed.deviceId,
67
+ ...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
68
+ since: typeof parsed.since === "number" ? parsed.since : Date.now(),
69
+ };
70
+ }
71
+
72
+ async function readStore(): Promise<TeleportStore> {
47
73
  if (cache !== undefined) return cache;
48
74
  try {
49
75
  const raw = await readFile(stateFile(), "utf8");
50
- const parsed = JSON.parse(raw) as Partial<TeleportState>;
51
- cache =
52
- parsed && typeof parsed.deviceId === "string" && parsed.deviceId
53
- ? {
54
- deviceId: parsed.deviceId,
55
- deviceName:
56
- typeof parsed.deviceName === "string" && parsed.deviceName
57
- ? parsed.deviceName
58
- : parsed.deviceId,
59
- ...(typeof parsed.cwd === "string" ? { cwd: parsed.cwd } : {}),
60
- since: typeof parsed.since === "number" ? parsed.since : Date.now(),
61
- }
62
- : null;
76
+ const parsed = JSON.parse(raw) as unknown;
77
+ const store = emptyStore();
78
+ if (
79
+ parsed &&
80
+ typeof parsed === "object" &&
81
+ "chats" in parsed &&
82
+ parsed.chats &&
83
+ typeof parsed.chats === "object"
84
+ ) {
85
+ for (const [chatId, state] of Object.entries(
86
+ parsed.chats as Record<string, unknown>,
87
+ )) {
88
+ const normalized = parseState(state);
89
+ if (normalized) store.chats[chatId] = normalized;
90
+ }
91
+ } else {
92
+ // Legacy singleton file from pre-chat-scoped teleport. Keep reading it
93
+ // harmlessly, but do not bind it to any chat.
94
+ }
95
+ cache = store;
63
96
  } catch {
64
- cache = null;
97
+ cache = emptyStore();
65
98
  }
66
99
  return cache;
67
100
  }
68
101
 
102
+ /** The active teleport target, or null when operating locally. */
103
+ export async function getTeleport(
104
+ chatId: number | string,
105
+ ): Promise<TeleportState | null> {
106
+ const store = await readStore();
107
+ return store.chats[normalizeChatId(chatId)] ?? null;
108
+ }
109
+
69
110
  /** Engage teleport onto a device. */
70
111
  export async function setTeleport(
112
+ chatId: number | string,
71
113
  deviceId: string,
72
114
  deviceName: string,
73
115
  ): Promise<TeleportState> {
@@ -76,25 +118,37 @@ export async function setTeleport(
76
118
  deviceName,
77
119
  since: Date.now(),
78
120
  };
79
- cache = state;
80
- await writePrivateJson(stateFile(), state);
121
+ const store = await readStore();
122
+ store.chats[normalizeChatId(chatId)] = state;
123
+ cache = store;
124
+ await writePrivateJson(stateFile(), store);
81
125
  return state;
82
126
  }
83
127
 
84
128
  /** Persist an updated working directory for the active teleport. */
85
- export async function setTeleportCwd(cwd: string): Promise<void> {
86
- const current = await getTeleport();
129
+ export async function setTeleportCwd(
130
+ chatId: number | string,
131
+ cwd: string,
132
+ ): Promise<void> {
133
+ const current = await getTeleport(chatId);
87
134
  if (!current) return;
88
135
  const next: TeleportState = { ...current, cwd };
89
- cache = next;
90
- await writePrivateJson(stateFile(), next);
136
+ const store = await readStore();
137
+ store.chats[normalizeChatId(chatId)] = next;
138
+ cache = store;
139
+ await writePrivateJson(stateFile(), store);
91
140
  }
92
141
 
93
142
  /** Return to local operation. Returns the prior target (for the message). */
94
- export async function clearTeleport(): Promise<TeleportState | null> {
95
- const prior = await getTeleport();
96
- cache = null;
97
- await writePrivateJson(stateFile(), null);
143
+ export async function clearTeleport(
144
+ chatId: number | string,
145
+ ): Promise<TeleportState | null> {
146
+ const store = await readStore();
147
+ const key = normalizeChatId(chatId);
148
+ const prior = store.chats[key] ?? null;
149
+ delete store.chats[key];
150
+ cache = store;
151
+ await writePrivateJson(stateFile(), store);
98
152
  return prior;
99
153
  }
100
154
 
@@ -12,7 +12,7 @@ const deviceParam = z
12
12
  .string()
13
13
  .optional()
14
14
  .describe(
15
- "Device id or part of the device name (see list_devices). Defaults to the most recently seen mobile mesh device.",
15
+ "Device id, exact name, or unique name fragment (case/separator-insensitive; see list_devices). A fragment matching several devices errors unless exactly one is online — prefer the id when duplicates exist. Defaults to the most recently seen mobile mesh device.",
16
16
  );
17
17
 
18
18
  export const meshTools: ToolDefinition[] = [
@@ -21,7 +21,7 @@ export const nativeTools: ToolDefinition[] = [
21
21
  .string()
22
22
  .optional()
23
23
  .describe(
24
- "Device id or name fragment (see list_devices). Defaults to the most recent mobile device.",
24
+ "Device id, exact name, or unique name fragment (case/separator-insensitive; see list_devices). Ambiguous fragments error unless exactly one match is online — prefer the id when duplicates exist. Defaults to the most recent mobile device.",
25
25
  ),
26
26
  },
27
27
  execute: (params, bridge) => bridge("teleport", params),
@@ -9,7 +9,7 @@ import type { Gateway } from "../../../core/engine/gateway.js";
9
9
  import { respawnSelf } from "../../../util/respawn.js";
10
10
  import { forceDream } from "../../../core/background/dream.js";
11
11
  import { formatDuration, renderMetricsMessages } from "../helpers.js";
12
- import { getMetrics } from "../../../util/metrics.js";
12
+ import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
13
13
  import { handleAdminSubcommand } from "../admin.js";
14
14
  import { isAdmin } from "../handlers/index.js";
15
15
  import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
@@ -35,7 +35,14 @@ export async function handleMetrics(
35
35
  }
36
36
  // Ephemeral — admin counters (token usage, latencies, errors) shouldn't leak
37
37
  // into a public channel where non-admins can read them.
38
- const messages = renderMetricsMessages(getMetrics());
38
+ const messages = [
39
+ ...renderMetricsMessages(getMetrics()),
40
+ ...renderMetricsMessages(
41
+ getTodayMetrics(),
42
+ undefined,
43
+ "📊 Metrics — today (UTC)",
44
+ ),
45
+ ];
39
46
  for (const m of messages) {
40
47
  await reply(i, m, true);
41
48
  }
@@ -35,7 +35,7 @@ type MetricsSnapshot = {
35
35
  counters: Record<string, number>;
36
36
  histograms: Record<
37
37
  string,
38
- { count: number; p50: number; p95: number; p99: number; avg: number }
38
+ { count: number; avg: number; min: number; max: number }
39
39
  >;
40
40
  };
41
41
 
@@ -50,14 +50,15 @@ function truncateMetricLabel(label: string, max = 60): string {
50
50
  export function renderMetricsMessages(
51
51
  metrics: MetricsSnapshot,
52
52
  maxLen = DEFAULT_METRICS_MESSAGE_MAX,
53
+ title = "📊 Metrics",
53
54
  ): string[] {
54
- const firstHeader = "**📊 Metrics**";
55
- const continuationHeader = "**📊 Metrics (cont.)**";
55
+ const firstHeader = `**${title}**`;
56
+ const continuationHeader = `**${title} (cont.)**`;
56
57
  const sections: string[][] = [];
57
58
 
58
59
  // Histograms come in two flavours: durations (keys ending in `_ms`,
59
60
  // rendered as human times) and plain counts like `tool_calls_per_turn`
60
- // (rendered as bare numbers — "p50=1ms" for a count is nonsense).
61
+ // (rendered as bare numbers — "min=1ms" for a count is nonsense).
61
62
  const histKeys = Object.keys(metrics.histograms).sort();
62
63
  const durationKeys = histKeys.filter((key) => key.endsWith("_ms"));
63
64
  const countKeys = histKeys.filter((key) => !key.endsWith("_ms"));
@@ -65,8 +66,8 @@ export function renderMetricsMessages(
65
66
  const h = metrics.histograms[key];
66
67
  return (
67
68
  ` \`${truncateMetricLabel(key)}\` n=${h.count} ` +
68
- `p50=${fmt(h.p50)} p95=${fmt(h.p95)} ` +
69
- `p99=${fmt(h.p99)} avg=${fmt(h.avg)}`
69
+ `avg=${fmt(h.avg)} min=${fmt(h.min)} ` +
70
+ `max=${fmt(h.max)}`
70
71
  );
71
72
  };
72
73
  if (durationKeys.length > 0) {
@@ -15,7 +15,7 @@ import {
15
15
  import {
16
16
  getAllSessions,
17
17
  setSessionName,
18
- resetSession,
18
+ deleteSession,
19
19
  } from "../../storage/sessions.js";
20
20
  import { getRecentHistory, clearHistory } from "../../storage/history.js";
21
21
  import { previewOf } from "./protocol.js";
@@ -125,7 +125,7 @@ export class NativeChats {
125
125
  if (!entry) return false;
126
126
  this.byId.delete(id);
127
127
  this.byNum.delete(entry.numericId);
128
- resetSession(id);
128
+ deleteSession(id);
129
129
  clearHistory(id);
130
130
  return true;
131
131
  }
@@ -23,7 +23,7 @@ import {
23
23
  } from "../helpers/index.js";
24
24
  import { collectDoctorReport } from "../../../core/doctor.js";
25
25
  import { handleAdminCommand } from "../admin.js";
26
- import { getMetrics } from "../../../util/metrics.js";
26
+ import { getMetrics, getTodayMetrics } from "../../../util/metrics.js";
27
27
  import { isAuthorizedAdmin, type RegisterDeps } from "./state.js";
28
28
  import { telegramCommandMenu } from "./definitions.js";
29
29
 
@@ -44,7 +44,15 @@ export function registerAdminCommands(
44
44
  await ctx.reply("Not authorized.");
45
45
  return;
46
46
  }
47
- for (const message of renderMetricsMessages(getMetrics())) {
47
+ const messages = [
48
+ ...renderMetricsMessages(getMetrics()),
49
+ ...renderMetricsMessages(
50
+ getTodayMetrics(),
51
+ undefined,
52
+ "📊 Metrics — today (UTC)",
53
+ ),
54
+ ];
55
+ for (const message of messages) {
48
56
  await ctx.reply(message, { parse_mode: "HTML" });
49
57
  }
50
58
  });
@@ -12,7 +12,7 @@ type MetricsSnapshot = {
12
12
  counters: Record<string, number>;
13
13
  histograms: Record<
14
14
  string,
15
- { count: number; p50: number; p95: number; p99: number; avg: number }
15
+ { count: number; avg: number; min: number; max: number }
16
16
  >;
17
17
  };
18
18
 
@@ -23,14 +23,15 @@ function truncateMetricLabel(label: string, max = 80): string {
23
23
  export function renderMetricsMessages(
24
24
  metrics: MetricsSnapshot,
25
25
  maxLen = DEFAULT_METRICS_MESSAGE_MAX,
26
+ title = "📊 Metrics",
26
27
  ): string[] {
27
- const firstHeader = "<b>📊 Metrics</b>";
28
- const continuationHeader = "<b>📊 Metrics (cont.)</b>";
28
+ const firstHeader = `<b>${escapeHtml(title)}</b>`;
29
+ const continuationHeader = `<b>${escapeHtml(title)} (cont.)</b>`;
29
30
  const sections: string[][] = [];
30
31
 
31
32
  // Histograms come in two flavours: durations (keys ending in `_ms`,
32
33
  // rendered as human times) and plain counts like `tool_calls_per_turn`
33
- // (rendered as bare numbers — "p50=1ms" for a count is nonsense).
34
+ // (rendered as bare numbers — "min=1ms" for a count is nonsense).
34
35
  const histKeys = Object.keys(metrics.histograms).sort();
35
36
  const durationKeys = histKeys.filter((key) => key.endsWith("_ms"));
36
37
  const countKeys = histKeys.filter((key) => !key.endsWith("_ms"));
@@ -38,8 +39,8 @@ export function renderMetricsMessages(
38
39
  const h = metrics.histograms[key];
39
40
  return (
40
41
  ` <code>${escapeHtml(truncateMetricLabel(key))}</code> n=${h.count} ` +
41
- `p50=${fmt(h.p50)} p95=${fmt(h.p95)} ` +
42
- `p99=${fmt(h.p99)} avg=${fmt(h.avg)}`
42
+ `avg=${fmt(h.avg)} min=${fmt(h.min)} ` +
43
+ `max=${fmt(h.max)}`
43
44
  );
44
45
  };
45
46
  if (durationKeys.length > 0) {
package/src/storage/db.ts CHANGED
@@ -68,7 +68,7 @@ let db: SqlDatabase | null = null;
68
68
  *
69
69
  * Column reconciliation runs first: `ALTER TABLE … ADD COLUMN` has no
70
70
  * IF NOT EXISTS form, so columns added to already-shipped tables
71
- * (media_index.content_hash) are ensured by attempting the ALTER and
71
+ * (media_index.content_hash, sessions.metrics) are ensured by attempting the ALTER and
72
72
  * swallowing the two expected failures — "duplicate column name"
73
73
  * (column already there) and "no such table" (fresh database; the
74
74
  * CREATE TABLE in schema.sql includes the column).
@@ -84,6 +84,11 @@ function ensureSchema(database: SqlDatabase): void {
84
84
  } catch {
85
85
  /* duplicate column or no such table — both mean nothing to do */
86
86
  }
87
+ try {
88
+ database.exec(dbSql.addSessionsMetricsColumn);
89
+ } catch {
90
+ /* duplicate column or no such table — both mean nothing to do */
91
+ }
87
92
  database.exec("BEGIN");
88
93
  try {
89
94
  database.exec(SCHEMA);