talon-agent 1.35.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 +3 -3
- package/src/backend/claude-sdk/handler.ts +6 -2
- package/src/backend/claude-sdk/options.ts +41 -1
- 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/bootstrap.ts +1 -0
- package/src/core/engine/gateway-actions/index.ts +2 -0
- package/src/core/engine/gateway-actions/mesh.ts +27 -0
- package/src/core/engine/gateway-actions/native.ts +607 -0
- package/src/core/mcp-hub/index.ts +3 -0
- package/src/core/mcp-hub/talon-server.ts +3 -0
- package/src/core/mesh/persist.ts +49 -0
- package/src/core/mesh/registry.ts +10 -18
- package/src/core/mesh/service.ts +754 -42
- package/src/core/mesh/teleport.ts +158 -0
- package/src/core/mesh/transfers.ts +186 -0
- package/src/core/tools/bridge.ts +85 -4
- package/src/core/tools/index.ts +23 -2
- package/src/core/tools/mesh.ts +83 -1
- package/src/core/tools/native.ts +138 -0
- package/src/core/tools/types.ts +2 -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/native/index.ts +2 -0
- package/src/frontend/native/server.ts +40 -1
- 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/config.ts +10 -0
- package/src/util/exec-output.ts +64 -0
- package/src/util/metrics.ts +148 -60
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native tools — Talon's own shell/filesystem primitives, the replacement
|
|
6
|
+
* for the SDK's built-in Bash/Read/Write/Edit/Glob/Grep. They are only
|
|
7
|
+
* surfaced when `config.nativeTools` is enabled (see the tool composition in
|
|
8
|
+
* the MCP server); otherwise the built-ins are used.
|
|
9
|
+
*
|
|
10
|
+
* Their extra power over the built-ins: every one honours the active
|
|
11
|
+
* `teleport` target, so with a teleport engaged they run ON a companion
|
|
12
|
+
* device (via the mesh exec/fs channel) instead of on the daemon host.
|
|
13
|
+
*/
|
|
14
|
+
export const nativeTools: ToolDefinition[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "teleport",
|
|
17
|
+
description:
|
|
18
|
+
"Switch Talon's native shell/file tools to run ON a companion mesh device (e.g. your phone). After teleporting, bash/read/write/edit/glob/search execute on that device until teleport_back. The device must be online and advertise the 'exec' capability.",
|
|
19
|
+
schema: {
|
|
20
|
+
device: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(
|
|
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
|
+
),
|
|
26
|
+
},
|
|
27
|
+
execute: (params, bridge) => bridge("teleport", params),
|
|
28
|
+
tag: "native",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "teleport_back",
|
|
32
|
+
description:
|
|
33
|
+
"Return native tools to the daemon host — bash/read/write/edit/glob/search run locally again.",
|
|
34
|
+
schema: {},
|
|
35
|
+
execute: (_params, bridge) => bridge("teleport_back", {}),
|
|
36
|
+
tag: "native",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "bash",
|
|
40
|
+
description:
|
|
41
|
+
"Run a shell command. Runs on the daemon host, or ON the active teleport device if one is engaged. On a teleported device, `cd` persists across calls (a real working-directory session).",
|
|
42
|
+
schema: {
|
|
43
|
+
command: z.string().describe("The shell command to run."),
|
|
44
|
+
cwd: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(
|
|
48
|
+
"Working directory (local runs only; teleport tracks its own cwd).",
|
|
49
|
+
),
|
|
50
|
+
timeout_sec: z
|
|
51
|
+
.number()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
"Max seconds before the command is killed (default 60, max 300).",
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
execute: (params, bridge) => bridge("native_bash", params),
|
|
58
|
+
tag: "native",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "read",
|
|
62
|
+
description:
|
|
63
|
+
"Read a file (with line numbers). Runs on the daemon host or the active teleport device. Supports offset/limit for large files.",
|
|
64
|
+
schema: {
|
|
65
|
+
path: z.string().describe("Absolute file path."),
|
|
66
|
+
offset: z.number().optional().describe("0-based line to start from."),
|
|
67
|
+
limit: z
|
|
68
|
+
.number()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Max lines to return (default/max 2000)."),
|
|
71
|
+
},
|
|
72
|
+
execute: (params, bridge) => bridge("native_read", params),
|
|
73
|
+
tag: "native",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "write",
|
|
77
|
+
description:
|
|
78
|
+
"Write (create/overwrite) a file with the given content. Runs on the daemon host or the active teleport device.",
|
|
79
|
+
schema: {
|
|
80
|
+
path: z.string().describe("Absolute file path."),
|
|
81
|
+
content: z.string().describe("Full file content to write."),
|
|
82
|
+
},
|
|
83
|
+
execute: (params, bridge) => bridge("native_write", params),
|
|
84
|
+
tag: "native",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "edit",
|
|
88
|
+
description:
|
|
89
|
+
"Exact-string replacement in a file. old_string must be unique unless replace_all is set. Runs on the daemon host or the active teleport device.",
|
|
90
|
+
schema: {
|
|
91
|
+
path: z.string().describe("Absolute file path."),
|
|
92
|
+
old_string: z.string().describe("Exact text to replace."),
|
|
93
|
+
new_string: z.string().describe("Replacement text."),
|
|
94
|
+
replace_all: z
|
|
95
|
+
.boolean()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Replace every occurrence (default false)."),
|
|
98
|
+
},
|
|
99
|
+
execute: (params, bridge) => bridge("native_edit", params),
|
|
100
|
+
tag: "native",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "glob",
|
|
104
|
+
description:
|
|
105
|
+
"Find files matching a glob pattern (ripgrep-backed). Runs on the daemon host or the active teleport device.",
|
|
106
|
+
schema: {
|
|
107
|
+
pattern: z.string().describe('Glob pattern, e.g. "**/*.ts".'),
|
|
108
|
+
path: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Root directory to search (default cwd)."),
|
|
112
|
+
},
|
|
113
|
+
execute: (params, bridge) => bridge("native_glob", params),
|
|
114
|
+
tag: "native",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "search",
|
|
118
|
+
description:
|
|
119
|
+
"Search file contents with a regex (ripgrep-backed). Runs on the daemon host or the active teleport device.",
|
|
120
|
+
schema: {
|
|
121
|
+
pattern: z.string().describe("Regex pattern to search for."),
|
|
122
|
+
path: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Root directory or file (default cwd)."),
|
|
126
|
+
glob: z
|
|
127
|
+
.string()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Filter files by glob, e.g. "*.ts".'),
|
|
130
|
+
case_insensitive: z
|
|
131
|
+
.boolean()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Case-insensitive match (default false)."),
|
|
134
|
+
},
|
|
135
|
+
execute: (params, bridge) => bridge("native_search", params),
|
|
136
|
+
tag: "native",
|
|
137
|
+
},
|
|
138
|
+
];
|
package/src/core/tools/types.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -1176,6 +1176,8 @@ export function createNativeFrontend(
|
|
|
1176
1176
|
storeLocation: (body) => mesh.storeLocation(body),
|
|
1177
1177
|
listDevices: () => mesh.list(),
|
|
1178
1178
|
completeCommand: (body) => mesh.completeCommand(body),
|
|
1179
|
+
acceptFileUpload: (token, body) => mesh.acceptFileUpload(token, body),
|
|
1180
|
+
openFileDownload: (token) => mesh.openFileDownload(token),
|
|
1179
1181
|
};
|
|
1180
1182
|
|
|
1181
1183
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
@@ -117,6 +117,15 @@ export type BridgeServerHandlers = {
|
|
|
117
117
|
| Promise<{ devices: DeviceInfo[]; locations: DeviceLocation[] }>;
|
|
118
118
|
/** A device answered a device_command; true when a call was waiting. */
|
|
119
119
|
completeCommand(body: Record<string, unknown>): boolean;
|
|
120
|
+
/** A device streams a pull-transfer's file body up (raw request body). */
|
|
121
|
+
acceptFileUpload(
|
|
122
|
+
token: string,
|
|
123
|
+
body: IncomingMessage,
|
|
124
|
+
): Promise<{ ok: true; bytes: number } | { ok: false; error: string }>;
|
|
125
|
+
/** Resolve a push-transfer token to the file to stream down, or null. */
|
|
126
|
+
openFileDownload(
|
|
127
|
+
token: string,
|
|
128
|
+
): Promise<{ path: string; size: number } | null>;
|
|
120
129
|
};
|
|
121
130
|
|
|
122
131
|
const SSE_PING_MS = 25_000;
|
|
@@ -268,7 +277,7 @@ export class BridgeServer {
|
|
|
268
277
|
backend: s.backend,
|
|
269
278
|
model: s.model,
|
|
270
279
|
activeChats: s.activeChats,
|
|
271
|
-
capabilities: ["mesh", "mesh-commands"],
|
|
280
|
+
capabilities: ["mesh", "mesh-commands", "mesh-file-stream"],
|
|
272
281
|
});
|
|
273
282
|
}
|
|
274
283
|
|
|
@@ -394,6 +403,36 @@ export class BridgeServer {
|
|
|
394
403
|
});
|
|
395
404
|
}
|
|
396
405
|
|
|
406
|
+
// Streamed device file transfers (see core/mesh/transfers.ts). The
|
|
407
|
+
// one-time `transfer` token authorizes exactly one direction+path;
|
|
408
|
+
// these sit behind the bridge bearer auth like every device route.
|
|
409
|
+
if (path === "/devices/file") {
|
|
410
|
+
const token = url.searchParams.get("transfer") ?? "";
|
|
411
|
+
if (!token)
|
|
412
|
+
return this.json(res, 400, { ok: false, error: "transfer required" });
|
|
413
|
+
if (method === "POST") {
|
|
414
|
+
const result = await this.handlers.acceptFileUpload(token, req);
|
|
415
|
+
return this.json(res, result.ok ? 200 : 409, result);
|
|
416
|
+
}
|
|
417
|
+
if (method === "GET") {
|
|
418
|
+
const file = await this.handlers.openFileDownload(token);
|
|
419
|
+
if (!file)
|
|
420
|
+
return this.json(res, 404, {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: "Unknown or already-used transfer token",
|
|
423
|
+
});
|
|
424
|
+
res.writeHead(200, {
|
|
425
|
+
"Content-Type": "application/octet-stream",
|
|
426
|
+
"Content-Length": String(file.size),
|
|
427
|
+
...this.corsHeaders(),
|
|
428
|
+
});
|
|
429
|
+
const stream = createReadStream(file.path);
|
|
430
|
+
stream.on("error", () => res.destroy());
|
|
431
|
+
stream.pipe(res);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
397
436
|
if (method === "POST" && path === "/upload") {
|
|
398
437
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
399
438
|
const contentType =
|
|
@@ -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);
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { getDatabase, inTransaction } from "../db.js";
|
|
16
16
|
import { sessionsSql } from "../sql/statements.generated.js";
|
|
17
|
-
import
|
|
17
|
+
import {
|
|
18
|
+
emptyMetrics,
|
|
19
|
+
normaliseMetrics,
|
|
20
|
+
type SessionMetrics,
|
|
21
|
+
type SessionState,
|
|
22
|
+
} from "../sessions.js";
|
|
18
23
|
|
|
19
24
|
type Row = {
|
|
20
25
|
chat_id: string;
|
|
@@ -37,8 +42,20 @@ type Row = {
|
|
|
37
42
|
total_response_ms: number;
|
|
38
43
|
last_response_ms: number;
|
|
39
44
|
fastest_response_ms: number | null;
|
|
45
|
+
metrics: string | null;
|
|
40
46
|
};
|
|
41
47
|
|
|
48
|
+
function parseMetrics(raw: string | null): SessionMetrics {
|
|
49
|
+
if (!raw) return emptyMetrics();
|
|
50
|
+
try {
|
|
51
|
+
// Normalise at the load boundary: backfills partial blobs and restores
|
|
52
|
+
// the Infinity minMs sentinel that JSON round-trips as null.
|
|
53
|
+
return normaliseMetrics(JSON.parse(raw));
|
|
54
|
+
} catch {
|
|
55
|
+
return emptyMetrics();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
function rowToSession(row: Row): SessionState {
|
|
43
60
|
return {
|
|
44
61
|
sessionId: row.session_id ?? undefined,
|
|
@@ -60,6 +77,7 @@ function rowToSession(row: Row): SessionState {
|
|
|
60
77
|
// NULL = no timed turn yet — the domain sentinel is Infinity.
|
|
61
78
|
fastestResponseMs: row.fastest_response_ms ?? Infinity,
|
|
62
79
|
},
|
|
80
|
+
metrics: parseMetrics(row.metrics),
|
|
63
81
|
lastBotMessageId: row.last_bot_message_id ?? undefined,
|
|
64
82
|
sessionName: row.session_name ?? undefined,
|
|
65
83
|
lastModel: row.last_model ?? undefined,
|
|
@@ -102,6 +120,7 @@ export function upsert(chatId: string, session: SessionState): void {
|
|
|
102
120
|
num(usage?.totalResponseMs),
|
|
103
121
|
num(usage?.lastResponseMs),
|
|
104
122
|
typeof fastest === "number" && Number.isFinite(fastest) ? fastest : null,
|
|
123
|
+
JSON.stringify(session.metrics ?? emptyMetrics()),
|
|
105
124
|
);
|
|
106
125
|
}
|
|
107
126
|
|