talon-agent 1.36.0 → 1.37.1
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
|
@@ -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
|
|
package/src/storage/sessions.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/storage/sql/db.sql
CHANGED
|
@@ -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;
|
|
@@ -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
|
+
}
|