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.
- package/package.json +1 -1
- package/src/backend/claude-sdk/handler.ts +6 -2
- package/src/backend/codex/handler/events.ts +1 -1
- package/src/backend/codex/handler/message.ts +1 -0
- package/src/backend/kilo/handler/message.ts +1 -0
- package/src/backend/kilo/handler/turn.ts +1 -0
- package/src/backend/openai-agents/handler/events.ts +1 -1
- package/src/backend/openai-agents/handler/message.ts +5 -1
- package/src/backend/opencode/handler/message.ts +1 -0
- package/src/backend/opencode/handler/turn.ts +1 -0
- package/src/backend/remote-server/events.ts +3 -1
- package/src/backend/shared/metrics.ts +26 -51
- package/src/core/engine/gateway-actions/native.ts +89 -56
- package/src/core/mesh/service.ts +137 -32
- package/src/core/mesh/teleport.ts +86 -32
- package/src/core/tools/mesh.ts +1 -1
- package/src/core/tools/native.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +9 -2
- package/src/frontend/discord/helpers.ts +7 -6
- package/src/frontend/native/chats.ts +2 -2
- package/src/frontend/telegram/commands/admin.ts +10 -2
- package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
- package/src/storage/db.ts +6 -1
- package/src/storage/repositories/sessions-repo.ts +20 -1
- package/src/storage/sessions.ts +348 -4
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/sessions.sql +3 -3
- package/src/storage/sql/statements.generated.ts +8 -4
- package/src/util/exec-output.ts +64 -0
- package/src/util/metrics.ts +148 -60
package/src/core/mesh/service.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
210
|
-
if (!
|
|
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,
|
|
257
|
-
|
|
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
|
|
293
|
-
if (
|
|
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
|
|
338
|
-
if (
|
|
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
|
|
550
|
-
if (
|
|
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(
|
|
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
|
-
|
|
611
|
-
|
|
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
|
|
631
|
-
if (
|
|
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
|
|
676
|
-
if (
|
|
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
|
-
|
|
768
|
-
if (
|
|
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
|
|
857
|
-
if (
|
|
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
|
|
882
|
-
*
|
|
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
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
1226
|
+
parts.push(
|
|
1227
|
+
`--- stdout ---\n${clampExecOutput(stdout.replace(/\s+$/, ""))}`,
|
|
1228
|
+
);
|
|
1126
1229
|
if (stderr.trim())
|
|
1127
|
-
parts.push(
|
|
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
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
46
|
+
let cache: TeleportStore | undefined;
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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
|
-
|
|
80
|
-
|
|
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(
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await
|
|
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
|
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -12,7 +12,7 @@ const deviceParam = z
|
|
|
12
12
|
.string()
|
|
13
13
|
.optional()
|
|
14
14
|
.describe(
|
|
15
|
-
"Device id
|
|
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[] = [
|
package/src/core/tools/native.ts
CHANGED
|
@@ -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 =
|
|
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;
|
|
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 =
|
|
55
|
-
const continuationHeader =
|
|
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 — "
|
|
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
|
-
`
|
|
69
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 =
|
|
28
|
-
const continuationHeader =
|
|
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 — "
|
|
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
|
-
`
|
|
42
|
-
`
|
|
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);
|