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.
Files changed (46) hide show
  1. package/package.json +3 -3
  2. package/src/backend/claude-sdk/handler.ts +6 -2
  3. package/src/backend/claude-sdk/options.ts +41 -1
  4. package/src/backend/codex/handler/events.ts +1 -1
  5. package/src/backend/codex/handler/message.ts +1 -0
  6. package/src/backend/kilo/handler/message.ts +1 -0
  7. package/src/backend/kilo/handler/turn.ts +1 -0
  8. package/src/backend/openai-agents/handler/events.ts +1 -1
  9. package/src/backend/openai-agents/handler/message.ts +5 -1
  10. package/src/backend/opencode/handler/message.ts +1 -0
  11. package/src/backend/opencode/handler/turn.ts +1 -0
  12. package/src/backend/remote-server/events.ts +3 -1
  13. package/src/backend/shared/metrics.ts +26 -51
  14. package/src/bootstrap.ts +1 -0
  15. package/src/core/engine/gateway-actions/index.ts +2 -0
  16. package/src/core/engine/gateway-actions/mesh.ts +27 -0
  17. package/src/core/engine/gateway-actions/native.ts +607 -0
  18. package/src/core/mcp-hub/index.ts +3 -0
  19. package/src/core/mcp-hub/talon-server.ts +3 -0
  20. package/src/core/mesh/persist.ts +49 -0
  21. package/src/core/mesh/registry.ts +10 -18
  22. package/src/core/mesh/service.ts +754 -42
  23. package/src/core/mesh/teleport.ts +158 -0
  24. package/src/core/mesh/transfers.ts +186 -0
  25. package/src/core/tools/bridge.ts +85 -4
  26. package/src/core/tools/index.ts +23 -2
  27. package/src/core/tools/mesh.ts +83 -1
  28. package/src/core/tools/native.ts +138 -0
  29. package/src/core/tools/types.ts +2 -1
  30. package/src/frontend/discord/commands/admin.ts +9 -2
  31. package/src/frontend/discord/helpers.ts +7 -6
  32. package/src/frontend/native/chats.ts +2 -2
  33. package/src/frontend/native/index.ts +2 -0
  34. package/src/frontend/native/server.ts +40 -1
  35. package/src/frontend/telegram/commands/admin.ts +10 -2
  36. package/src/frontend/telegram/helpers/diagnostics.ts +7 -6
  37. package/src/storage/db.ts +6 -1
  38. package/src/storage/repositories/sessions-repo.ts +20 -1
  39. package/src/storage/sessions.ts +348 -4
  40. package/src/storage/sql/db.sql +5 -0
  41. package/src/storage/sql/schema.sql +2 -1
  42. package/src/storage/sql/sessions.sql +3 -3
  43. package/src/storage/sql/statements.generated.ts +8 -4
  44. package/src/util/config.ts +10 -0
  45. package/src/util/exec-output.ts +64 -0
  46. 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
+ ];
@@ -27,7 +27,8 @@ export type ToolTag =
27
27
  | "web"
28
28
  | "admin"
29
29
  | "models"
30
- | "mesh";
30
+ | "mesh"
31
+ | "native";
31
32
 
32
33
  /** The bridge caller signature — injected into execute(). */
33
34
  export type BridgeFunction = (
@@ -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
  }
@@ -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
- 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);
@@ -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 type { SessionState } from "../sessions.js";
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