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
@@ -47,6 +47,43 @@ export type SessionUsage = {
47
47
  fastestResponseMs: number;
48
48
  };
49
49
 
50
+ export type MetricsCounterSet = {
51
+ queries: number;
52
+ toolCalls: number;
53
+ turnsWithTools: number;
54
+ apiCalls: number;
55
+ inputTokens: number;
56
+ outputTokens: number;
57
+ cacheReadTokens: number;
58
+ cacheWriteTokens: number;
59
+ failedTurns: number;
60
+ flowViolationRetries: number;
61
+ flowViolationCapExhausted: number;
62
+ trailingTextDropped: number;
63
+ };
64
+
65
+ export type MetricsLatencyAgg = {
66
+ count: number;
67
+ sumMs: number;
68
+ minMs: number;
69
+ maxMs: number;
70
+ };
71
+
72
+ export type MetricsGrain = {
73
+ counters: MetricsCounterSet;
74
+ latency: MetricsLatencyAgg;
75
+ toolCallsByName: Record<string, number>;
76
+ backend: Record<string, MetricsCounterSet & { latency: MetricsLatencyAgg }>;
77
+ cacheHitPercent: MetricsLatencyAgg;
78
+ toolCallsPerTurn: MetricsLatencyAgg;
79
+ apiCallsPerTurn: MetricsLatencyAgg;
80
+ };
81
+
82
+ export type SessionMetrics = {
83
+ lifetime: MetricsGrain;
84
+ buckets: Record<string, MetricsGrain>;
85
+ };
86
+
50
87
  export type SessionState = {
51
88
  /** Backend server-side session ID. */
52
89
  sessionId: string | undefined;
@@ -58,6 +95,8 @@ export type SessionState = {
58
95
  createdAt: number;
59
96
  /** Cumulative usage stats. */
60
97
  usage: SessionUsage;
98
+ /** Persistent per-session metrics, bucketed by UTC day. */
99
+ metrics: SessionMetrics;
61
100
  /** ID of the last message sent by the bot in this chat. */
62
101
  lastBotMessageId?: number;
63
102
  /** Descriptive session name derived from first message. */
@@ -249,6 +288,155 @@ const emptyUsage = (): SessionUsage => ({
249
288
  fastestResponseMs: Infinity,
250
289
  });
251
290
 
291
+ const emptyCounters = (): MetricsCounterSet => ({
292
+ queries: 0,
293
+ toolCalls: 0,
294
+ turnsWithTools: 0,
295
+ apiCalls: 0,
296
+ inputTokens: 0,
297
+ outputTokens: 0,
298
+ cacheReadTokens: 0,
299
+ cacheWriteTokens: 0,
300
+ failedTurns: 0,
301
+ flowViolationRetries: 0,
302
+ flowViolationCapExhausted: 0,
303
+ trailingTextDropped: 0,
304
+ });
305
+
306
+ const emptyLatency = (): MetricsLatencyAgg => ({
307
+ count: 0,
308
+ sumMs: 0,
309
+ minMs: Infinity,
310
+ maxMs: 0,
311
+ });
312
+
313
+ const emptyGrain = (): MetricsGrain => ({
314
+ counters: emptyCounters(),
315
+ latency: emptyLatency(),
316
+ toolCallsByName: {},
317
+ backend: {},
318
+ cacheHitPercent: emptyLatency(),
319
+ toolCallsPerTurn: emptyLatency(),
320
+ apiCallsPerTurn: emptyLatency(),
321
+ });
322
+
323
+ export const emptyMetrics = (): SessionMetrics => ({
324
+ lifetime: emptyGrain(),
325
+ buckets: {},
326
+ });
327
+
328
+ function normaliseLatency(raw: unknown): MetricsLatencyAgg {
329
+ const r = raw as Partial<MetricsLatencyAgg> | undefined;
330
+ const count =
331
+ typeof r?.count === "number" && Number.isFinite(r.count) ? r.count : 0;
332
+ return {
333
+ count,
334
+ sumMs:
335
+ typeof r?.sumMs === "number" && Number.isFinite(r.sumMs) ? r.sumMs : 0,
336
+ minMs:
337
+ typeof r?.minMs === "number" && Number.isFinite(r.minMs)
338
+ ? r.minMs
339
+ : count > 0
340
+ ? 0
341
+ : Infinity,
342
+ maxMs:
343
+ typeof r?.maxMs === "number" && Number.isFinite(r.maxMs) ? r.maxMs : 0,
344
+ };
345
+ }
346
+
347
+ function normaliseCounters(raw: unknown): MetricsCounterSet {
348
+ const base = emptyCounters();
349
+ const r = raw as Partial<MetricsCounterSet> | undefined;
350
+ for (const key of Object.keys(base) as Array<keyof MetricsCounterSet>) {
351
+ const value = r?.[key];
352
+ if (typeof value === "number" && Number.isFinite(value)) base[key] = value;
353
+ }
354
+ return base;
355
+ }
356
+
357
+ function normaliseGrain(raw: unknown): MetricsGrain {
358
+ const r = raw as Partial<MetricsGrain> | undefined;
359
+ const grain = emptyGrain();
360
+ grain.counters = normaliseCounters(r?.counters);
361
+ grain.latency = normaliseLatency(r?.latency);
362
+ grain.cacheHitPercent = normaliseLatency(r?.cacheHitPercent);
363
+ grain.toolCallsPerTurn = normaliseLatency(r?.toolCallsPerTurn);
364
+ grain.apiCallsPerTurn = normaliseLatency(r?.apiCallsPerTurn);
365
+ if (r?.toolCallsByName && typeof r.toolCallsByName === "object") {
366
+ for (const [key, value] of Object.entries(r.toolCallsByName)) {
367
+ if (typeof value === "number" && Number.isFinite(value)) {
368
+ grain.toolCallsByName[key] = value;
369
+ }
370
+ }
371
+ }
372
+ if (r?.backend && typeof r.backend === "object") {
373
+ for (const [key, value] of Object.entries(r.backend)) {
374
+ grain.backend[key] = {
375
+ ...normaliseCounters(value),
376
+ latency: normaliseLatency((value as { latency?: unknown }).latency),
377
+ };
378
+ }
379
+ }
380
+ return grain;
381
+ }
382
+
383
+ /**
384
+ * Coerce an untrusted/persisted metrics blob into a well-formed
385
+ * SessionMetrics: missing fields backfilled, non-finite numbers dropped,
386
+ * JSON's `minMs: null` (Infinity doesn't survive JSON.stringify) restored
387
+ * to the domain sentinel. Runs once at the load boundary (sessions-repo);
388
+ * in-memory metrics stay well-formed by construction after that.
389
+ */
390
+ export function normaliseMetrics(metrics: unknown): SessionMetrics {
391
+ const raw = metrics as Partial<SessionMetrics> | undefined;
392
+ const result: SessionMetrics = {
393
+ lifetime: normaliseGrain(raw?.lifetime),
394
+ buckets: {},
395
+ };
396
+ if (raw?.buckets && typeof raw.buckets === "object") {
397
+ for (const [day, grain] of Object.entries(raw.buckets)) {
398
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day))
399
+ result.buckets[day] = normaliseGrain(grain);
400
+ }
401
+ }
402
+ return result;
403
+ }
404
+
405
+ function addLatency(agg: MetricsLatencyAgg, value: number): void {
406
+ if (!Number.isFinite(value)) return;
407
+ agg.count += 1;
408
+ agg.sumMs += value;
409
+ agg.minMs = Math.min(agg.minMs, value);
410
+ agg.maxMs = Math.max(agg.maxMs, value);
411
+ }
412
+
413
+ function addCounters(
414
+ target: MetricsCounterSet,
415
+ delta: Partial<MetricsCounterSet>,
416
+ ): void {
417
+ for (const [key, value] of Object.entries(delta) as Array<
418
+ [keyof MetricsCounterSet, number | undefined]
419
+ >) {
420
+ if (typeof value === "number" && Number.isFinite(value))
421
+ target[key] += value;
422
+ }
423
+ }
424
+
425
+ /** UTC day key (`YYYY-MM-DD`) used for daily metric buckets. */
426
+ export function todayUtc(now = Date.now()): string {
427
+ return new Date(now).toISOString().slice(0, 10);
428
+ }
429
+
430
+ function metricBucket(metrics: SessionMetrics, day: string): MetricsGrain {
431
+ metrics.buckets[day] ??= emptyGrain();
432
+ const days = Object.keys(metrics.buckets).sort();
433
+ while (days.length > 30) {
434
+ const oldest = days.shift();
435
+ if (oldest) delete metrics.buckets[oldest];
436
+ }
437
+ return metrics.buckets[day]!;
438
+ }
439
+
252
440
  /**
253
441
  * Normalise a session state object in-place. Migrates fields that
254
442
  * pre-date later schema additions (response timing, context fill,
@@ -256,6 +444,9 @@ const emptyUsage = (): SessionUsage => ({
256
444
  */
257
445
  function normaliseSession(session: SessionState): SessionState {
258
446
  if (!session.usage) session.usage = emptyUsage();
447
+ // Metrics blobs are normalised once when rows are hydrated
448
+ // (sessions-repo parseMetrics); here only backfill pre-metrics records.
449
+ if (!session.metrics) session.metrics = emptyMetrics();
259
450
  if (!session.createdAt) session.createdAt = session.lastActive;
260
451
  if (session.usage.totalResponseMs === undefined)
261
452
  session.usage.totalResponseMs = 0;
@@ -285,6 +476,7 @@ export function getSession(chatId: string): SessionState {
285
476
  lastActive: now,
286
477
  createdAt: now,
287
478
  usage: emptyUsage(),
479
+ metrics: emptyMetrics(),
288
480
  };
289
481
  cache.set(chatId, session);
290
482
  persist(chatId, session);
@@ -292,6 +484,114 @@ export function getSession(chatId: string): SessionState {
292
484
  return normaliseSession(session);
293
485
  }
294
486
 
487
+ export type SessionMetricTurn = {
488
+ backend: string;
489
+ durationMs: number;
490
+ toolCalls?: number;
491
+ toolCallsByName?: Record<string, number>;
492
+ apiCalls?: number;
493
+ failed?: boolean;
494
+ usage?: {
495
+ inputTokens: number;
496
+ outputTokens: number;
497
+ cacheRead: number;
498
+ cacheWrite: number;
499
+ };
500
+ };
501
+
502
+ function foldMetricTurn(grain: MetricsGrain, turn: SessionMetricTurn): void {
503
+ addCounters(grain.counters, {
504
+ queries: 1,
505
+ turnsWithTools: turn.toolCalls && turn.toolCalls > 0 ? 1 : 0,
506
+ apiCalls: turn.apiCalls ?? 0,
507
+ failedTurns: turn.failed ? 1 : 0,
508
+ inputTokens: turn.usage?.inputTokens ?? 0,
509
+ outputTokens: turn.usage?.outputTokens ?? 0,
510
+ cacheReadTokens: turn.usage?.cacheRead ?? 0,
511
+ cacheWriteTokens: turn.usage?.cacheWrite ?? 0,
512
+ });
513
+ addLatency(grain.latency, turn.durationMs);
514
+ if (turn.toolCalls !== undefined)
515
+ addLatency(grain.toolCallsPerTurn, turn.toolCalls);
516
+ if (turn.apiCalls !== undefined && turn.apiCalls > 0)
517
+ addLatency(grain.apiCallsPerTurn, turn.apiCalls);
518
+ if (turn.usage && turn.usage.inputTokens + turn.usage.cacheRead > 0) {
519
+ addLatency(
520
+ grain.cacheHitPercent,
521
+ Math.round(
522
+ (turn.usage.cacheRead /
523
+ (turn.usage.inputTokens + turn.usage.cacheRead)) *
524
+ 100,
525
+ ),
526
+ );
527
+ }
528
+ for (const [name, count] of Object.entries(turn.toolCallsByName ?? {})) {
529
+ if (Number.isFinite(count))
530
+ grain.toolCallsByName[name] = (grain.toolCallsByName[name] ?? 0) + count;
531
+ }
532
+ const backend = (grain.backend[turn.backend] ??= {
533
+ ...emptyCounters(),
534
+ latency: emptyLatency(),
535
+ });
536
+ addCounters(backend, {
537
+ queries: 1,
538
+ apiCalls: turn.apiCalls ?? 0,
539
+ failedTurns: turn.failed ? 1 : 0,
540
+ inputTokens: turn.usage?.inputTokens ?? 0,
541
+ outputTokens: turn.usage?.outputTokens ?? 0,
542
+ cacheReadTokens: turn.usage?.cacheRead ?? 0,
543
+ cacheWriteTokens: turn.usage?.cacheWrite ?? 0,
544
+ });
545
+ addLatency(backend.latency, turn.durationMs);
546
+ }
547
+
548
+ export function recordSessionMetrics(
549
+ chatId: string,
550
+ turn: SessionMetricTurn,
551
+ day = todayUtc(),
552
+ ): void {
553
+ const session = getSession(chatId);
554
+ foldMetricTurn(session.metrics.lifetime, turn);
555
+ foldMetricTurn(metricBucket(session.metrics, day), turn);
556
+ persist(chatId, session);
557
+ }
558
+
559
+ export function recordSessionMetricEvent(
560
+ chatId: string,
561
+ event: Partial<MetricsCounterSet> & {
562
+ toolName?: string;
563
+ backend?: string;
564
+ },
565
+ day = todayUtc(),
566
+ ): void {
567
+ const session = getSession(chatId);
568
+ for (const grain of [
569
+ session.metrics.lifetime,
570
+ metricBucket(session.metrics, day),
571
+ ]) {
572
+ addCounters(grain.counters, event);
573
+ if (event.toolName && event.toolCalls) {
574
+ grain.toolCallsByName[event.toolName] =
575
+ (grain.toolCallsByName[event.toolName] ?? 0) + event.toolCalls;
576
+ }
577
+ if (event.backend) {
578
+ const backend = (grain.backend[event.backend] ??= {
579
+ ...emptyCounters(),
580
+ latency: emptyLatency(),
581
+ });
582
+ addCounters(backend, event);
583
+ }
584
+ }
585
+ persist(chatId, session);
586
+ }
587
+
588
+ export function resetAllSessionMetrics(): void {
589
+ for (const [chatId, session] of cache.entries()) {
590
+ session.metrics = emptyMetrics();
591
+ persist(chatId, session);
592
+ }
593
+ }
594
+
295
595
  export function setSessionId(chatId: string, sessionId: string): void {
296
596
  const session = getSession(chatId);
297
597
  session.sessionId = sessionId;
@@ -375,10 +675,7 @@ export function getLastBotMessageId(chatId: string): number | undefined {
375
675
  return cache.get(chatId)?.lastBotMessageId;
376
676
  }
377
677
 
378
- export function resetSession(chatId: string): void {
379
- const session = cache.get(chatId);
380
- const turns = session?.turns ?? 0;
381
- const name = session?.sessionName;
678
+ function removeSessionRow(chatId: string): void {
382
679
  cache.delete(chatId);
383
680
  clearLiveTurn(chatId);
384
681
  try {
@@ -389,18 +686,63 @@ export function resetSession(chatId: string): void {
389
686
  `Session delete failed: ${err instanceof Error ? err.message : err}`,
390
687
  );
391
688
  }
689
+ }
690
+
691
+ /**
692
+ * Reset the chat's conversation state (backend session id, turns, usage)
693
+ * while carrying its metrics forward. Resets fire on /new, model switches
694
+ * and error recovery — none of which should erase the chat's accounting
695
+ * history (that's what makes per-session metrics survive anything short
696
+ * of deleting the chat). Use deleteSession() to drop the chat entirely.
697
+ */
698
+ export function resetSession(chatId: string): void {
699
+ const session = cache.get(chatId);
700
+ const turns = session?.turns ?? 0;
701
+ const name = session?.sessionName;
702
+ const metrics = session?.metrics;
703
+ removeSessionRow(chatId);
704
+ const hasHistory =
705
+ metrics &&
706
+ (metrics.lifetime.counters.queries > 0 ||
707
+ metrics.lifetime.counters.toolCalls > 0);
708
+ if (hasHistory) {
709
+ const now = Date.now();
710
+ const fresh: SessionState = {
711
+ sessionId: undefined,
712
+ turns: 0,
713
+ lastActive: now,
714
+ createdAt: now,
715
+ usage: emptyUsage(),
716
+ metrics,
717
+ };
718
+ cache.set(chatId, fresh);
719
+ persist(chatId, fresh);
720
+ }
392
721
  log(
393
722
  "sessions",
394
723
  `[${chatId}] Reset${name ? ` "${name}"` : ""} (${turns} turns)`,
395
724
  );
396
725
  }
397
726
 
727
+ /** Drop the chat entirely — row, cache, live overlay and metrics. */
728
+ export function deleteSession(chatId: string): void {
729
+ const session = cache.get(chatId);
730
+ const turns = session?.turns ?? 0;
731
+ const name = session?.sessionName;
732
+ removeSessionRow(chatId);
733
+ log(
734
+ "sessions",
735
+ `[${chatId}] Deleted${name ? ` "${name}"` : ""} (${turns} turns)`,
736
+ );
737
+ }
738
+
398
739
  export type SessionInfo = {
399
740
  sessionId: string | undefined;
400
741
  turns: number;
401
742
  lastActive: number;
402
743
  createdAt: number;
403
744
  usage: SessionUsage;
745
+ metrics: SessionMetrics;
404
746
  sessionName?: string;
405
747
  lastModel?: string;
406
748
  /** True when a turn is currently streaming and `usage` includes its live so-far counts. */
@@ -415,6 +757,7 @@ export function getSessionInfo(chatId: string): SessionInfo {
415
757
  lastActive: session?.lastActive ?? 0,
416
758
  createdAt: session?.createdAt ?? 0,
417
759
  usage: withLiveTurn(chatId, session?.usage ?? emptyUsage()),
760
+ metrics: session?.metrics ?? emptyMetrics(),
418
761
  sessionName: session?.sessionName,
419
762
  lastModel: session?.lastModel,
420
763
  turnInProgress: liveTurns.has(chatId),
@@ -435,6 +778,7 @@ export function getAllSessions(): Array<{ chatId: string; info: SessionInfo }> {
435
778
  lastActive: session.lastActive,
436
779
  createdAt: session.createdAt,
437
780
  usage: withLiveTurn(chatId, session.usage ?? emptyUsage()),
781
+ metrics: session.metrics ?? emptyMetrics(),
438
782
  sessionName: session.sessionName,
439
783
  lastModel: session.lastModel,
440
784
  turnInProgress: liveTurns.has(chatId),
@@ -9,3 +9,8 @@ PRAGMA wal_checkpoint(TRUNCATE)
9
9
  -- attempts this on every open and swallows "duplicate column name" /
10
10
  -- "no such table" (fresh databases get the column via schema.sql).
11
11
  ALTER TABLE media_index ADD COLUMN content_hash TEXT
12
+
13
+ -- name: addSessionsMetricsColumn
14
+ -- Column reconciliation for databases that shipped before per-session
15
+ -- metrics existed. Fresh databases get the column via schema.sql.
16
+ ALTER TABLE sessions ADD COLUMN metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'
@@ -70,7 +70,8 @@ CREATE TABLE IF NOT EXISTS sessions (
70
70
  estimated_cost_usd REAL NOT NULL DEFAULT 0,
71
71
  total_response_ms REAL NOT NULL DEFAULT 0,
72
72
  last_response_ms REAL NOT NULL DEFAULT 0,
73
- fastest_response_ms REAL
73
+ fastest_response_ms REAL,
74
+ metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'
74
75
  );
75
76
 
76
77
  -- Chat settings: one JSON document per chat. The access pattern is
@@ -7,15 +7,15 @@ INSERT OR REPLACE INTO sessions
7
7
  created_at, last_bot_message_id, total_input_tokens, total_output_tokens,
8
8
  total_cache_read, total_cache_write, last_prompt_tokens, context_tokens,
9
9
  context_window, num_api_calls, estimated_cost_usd, total_response_ms,
10
- last_response_ms, fastest_response_ms)
11
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10
+ last_response_ms, fastest_response_ms, metrics)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
12
12
 
13
13
  -- name: all
14
14
  SELECT chat_id, session_id, session_name, last_model, turns, last_active,
15
15
  created_at, last_bot_message_id, total_input_tokens, total_output_tokens,
16
16
  total_cache_read, total_cache_write, last_prompt_tokens, context_tokens,
17
17
  context_window, num_api_calls, estimated_cost_usd, total_response_ms,
18
- last_response_ms, fastest_response_ms
18
+ last_response_ms, fastest_response_ms, metrics
19
19
  FROM sessions
20
20
 
21
21
  -- name: remove
@@ -75,7 +75,8 @@ CREATE TABLE IF NOT EXISTS sessions (
75
75
  estimated_cost_usd REAL NOT NULL DEFAULT 0,
76
76
  total_response_ms REAL NOT NULL DEFAULT 0,
77
77
  last_response_ms REAL NOT NULL DEFAULT 0,
78
- fastest_response_ms REAL
78
+ fastest_response_ms REAL,
79
+ metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'
79
80
  );
80
81
 
81
82
  -- Chat settings: one JSON document per chat. The access pattern is
@@ -277,6 +278,9 @@ export const dbSql = {
277
278
  -- attempts this on every open and swallows "duplicate column name" /
278
279
  -- "no such table" (fresh databases get the column via schema.sql).
279
280
  ALTER TABLE media_index ADD COLUMN content_hash TEXT`,
281
+ addSessionsMetricsColumn: `-- Column reconciliation for databases that shipped before per-session
282
+ -- metrics existed. Fresh databases get the column via schema.sql.
283
+ ALTER TABLE sessions ADD COLUMN metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'`,
280
284
  } as const;
281
285
 
282
286
  export const goalsSql = {
@@ -420,13 +424,13 @@ export const sessionsSql = {
420
424
  created_at, last_bot_message_id, total_input_tokens, total_output_tokens,
421
425
  total_cache_read, total_cache_write, last_prompt_tokens, context_tokens,
422
426
  context_window, num_api_calls, estimated_cost_usd, total_response_ms,
423
- last_response_ms, fastest_response_ms)
424
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
427
+ last_response_ms, fastest_response_ms, metrics)
428
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
425
429
  all: `SELECT chat_id, session_id, session_name, last_model, turns, last_active,
426
430
  created_at, last_bot_message_id, total_input_tokens, total_output_tokens,
427
431
  total_cache_read, total_cache_write, last_prompt_tokens, context_tokens,
428
432
  context_window, num_api_calls, estimated_cost_usd, total_response_ms,
429
- last_response_ms, fastest_response_ms
433
+ last_response_ms, fastest_response_ms, metrics
430
434
  FROM sessions`,
431
435
  remove: `DELETE FROM sessions WHERE chat_id = ?`,
432
436
  } as const;
@@ -422,6 +422,16 @@ const configSchema = z.object({
422
422
  // Native — local bridge for the Electron companion app (apps/desktop)
423
423
  native: nativeConfigSchema.optional(),
424
424
 
425
+ // Native tools — replace the SDK's built-in Read/Write/Edit/Bash/Glob/Grep
426
+ // with Talon's own MCP equivalents (bash/read/write/edit/glob/search). The
427
+ // native tools additionally route to the active `teleport` node, so file
428
+ // and shell operations can transparently run on a companion device. When
429
+ // true, the built-ins are dropped from the model's tool whitelist and the
430
+ // native tools take their place. Off by default: enabling swaps the model's
431
+ // own hands, so the cutover should be deliberate and observed (flip to true
432
+ // and restart), with an instant rollback by flipping back to false.
433
+ nativeTools: z.boolean().default(false),
434
+
425
435
  // Display name shown in terminal UI (defaults to "Talon")
426
436
  botDisplayName: z.string().default("Talon"),
427
437
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Exec output guards, shared by the local native tools and the mesh exec
3
+ * channel. Two independent ceilings:
4
+ *
5
+ * - capture: how much of a child process's stream the daemon will hold in
6
+ * memory at all. A runaway `yes` otherwise balloons RSS for the whole
7
+ * timeout window before a single byte reaches the model.
8
+ * - render: how much of a (possibly device-supplied) stream reaches the
9
+ * model/chat. Clamped head+tail — the tail keeps the part of a long log
10
+ * where the error usually lives.
11
+ */
12
+
13
+ /** In-memory cap while capturing one child-process output stream. */
14
+ export const MAX_EXEC_CAPTURE_BYTES = 4 * 1024 * 1024;
15
+
16
+ /** Ceiling on each rendered exec stream (stdout, stderr independently). */
17
+ export const MAX_EXEC_RENDER_CHARS = 30_000;
18
+
19
+ /**
20
+ * Clamp one output stream for rendering: keep the head and the tail,
21
+ * elide the middle with an explicit marker so truncation is never silent.
22
+ */
23
+ export function clampExecOutput(
24
+ text: string,
25
+ max = MAX_EXEC_RENDER_CHARS,
26
+ ): string {
27
+ if (text.length <= max) return text;
28
+ const headLen = Math.floor(max * 0.75);
29
+ const tailLen = max - headLen;
30
+ const head = text.slice(0, headLen);
31
+ const tail = text.slice(-tailLen);
32
+ const elided = text.length - head.length - tail.length;
33
+ return `${head}\n…[${elided.toLocaleString()} chars truncated]…\n${tail}`;
34
+ }
35
+
36
+ /**
37
+ * Bounded accumulator for a child process's stdout/stderr. Chunks past the
38
+ * cap are counted, not stored; `value()` appends an explicit drop marker.
39
+ */
40
+ export function createOutputCapture(cap = MAX_EXEC_CAPTURE_BYTES): {
41
+ push: (chunk: Buffer | string) => void;
42
+ value: () => string;
43
+ } {
44
+ let text = "";
45
+ let dropped = 0;
46
+ return {
47
+ push: (chunk) => {
48
+ const s = chunk.toString();
49
+ const room = cap - text.length;
50
+ if (room <= 0) {
51
+ dropped += s.length;
52
+ } else if (s.length <= room) {
53
+ text += s;
54
+ } else {
55
+ text += s.slice(0, room);
56
+ dropped += s.length - room;
57
+ }
58
+ },
59
+ value: () =>
60
+ dropped > 0
61
+ ? `${text}\n…[${dropped.toLocaleString()} more chars dropped — output exceeded the ${Math.round(cap / (1024 * 1024))}MB capture cap]`
62
+ : text,
63
+ };
64
+ }