@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.6
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/CHANGELOG.md +29 -0
- package/dist/cli.js +3052 -3005
- package/dist/types/cli/bench-cli.d.ts +1 -7
- package/dist/types/cli/usage-cli.d.ts +1 -0
- package/dist/types/commands/usage.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +3 -8
- package/dist/types/modes/components/model-browser.d.ts +3 -0
- package/dist/types/modes/components/model-hub.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/queue-input.d.ts +8 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-storage.d.ts +57 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +0 -1
- package/scripts/compile-binary.ts +4 -3
- package/src/cli/bench-cli.ts +7 -26
- package/src/cli/usage-cli.ts +11 -0
- package/src/commands/usage.ts +13 -2
- package/src/config/settings-schema.ts +1 -1
- package/src/modes/components/advisor-config.ts +3 -1
- package/src/modes/components/custom-editor.test.ts +58 -1
- package/src/modes/components/custom-editor.ts +42 -11
- package/src/modes/components/model-browser.ts +114 -33
- package/src/modes/components/model-hub.ts +455 -108
- package/src/modes/components/usage-row.ts +5 -6
- package/src/modes/controllers/input-controller.ts +140 -6
- package/src/modes/controllers/selector-controller.ts +20 -13
- package/src/modes/controllers/todo-command-controller.ts +1 -2
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/queue-input.ts +132 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +19 -20
- package/src/session/agent-session.ts +184 -48
- package/src/session/agent-storage.ts +330 -3
- package/src/session/history-storage.ts +1 -34
- package/src/slash-commands/builtin-registry.ts +9 -0
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
SqliteAuthCredentialStore,
|
|
9
9
|
type StoredAuthCredential,
|
|
10
10
|
} from "@oh-my-pi/pi-ai";
|
|
11
|
-
import { getAgentDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { AsyncDrain, getAgentDbPath, getStatsDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import type { RawSettings as Settings } from "../config/settings";
|
|
13
13
|
|
|
14
14
|
/** Row shape for settings table queries */
|
|
@@ -23,8 +23,100 @@ type ModelUsageRow = {
|
|
|
23
23
|
last_used_at: number;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/** Row shape for model_perf table queries */
|
|
27
|
+
type ModelPerfRow = {
|
|
28
|
+
model_key: string;
|
|
29
|
+
samples: number;
|
|
30
|
+
output_tokens: number;
|
|
31
|
+
gen_ms: number;
|
|
32
|
+
ttft_samples: number;
|
|
33
|
+
ttft_ms: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Row shape read from an `omp stats` messages table during backfill. */
|
|
37
|
+
type StatsMessageRow = {
|
|
38
|
+
rowid: number;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
provider: string;
|
|
41
|
+
model: string;
|
|
42
|
+
output_tokens: number;
|
|
43
|
+
duration: number;
|
|
44
|
+
ttft: number | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Per-model running sums accumulated during a backfill walk. */
|
|
48
|
+
type PerfAccum = {
|
|
49
|
+
samples: number;
|
|
50
|
+
outputTokens: number;
|
|
51
|
+
genMs: number;
|
|
52
|
+
ttftSamples: number;
|
|
53
|
+
ttftMs: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** One completed request's timing, folded into the per-model aggregates. */
|
|
57
|
+
export interface ModelPerfSample {
|
|
58
|
+
/** Output tokens the provider reported for the turn. */
|
|
59
|
+
outputTokens: number;
|
|
60
|
+
/** Total request duration in milliseconds. */
|
|
61
|
+
durationMs: number;
|
|
62
|
+
/** Time to first token in milliseconds; omit when the provider did not report one. */
|
|
63
|
+
ttftMs?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validated, insert-ready model_perf sample (see {@link normalizeModelPerfSample}). */
|
|
67
|
+
type ModelPerfInsert = {
|
|
68
|
+
modelKey: string;
|
|
69
|
+
outputTokens: number;
|
|
70
|
+
durationMs: number;
|
|
71
|
+
ttftSamples: 0 | 1;
|
|
72
|
+
ttftMs: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Recency-weighted per-model performance averages. */
|
|
76
|
+
export interface ModelPerfStats {
|
|
77
|
+
/** Decayed sample count backing the averages. */
|
|
78
|
+
samples: number;
|
|
79
|
+
/** Average output tokens/sec over the total request duration. */
|
|
80
|
+
tps: number;
|
|
81
|
+
/** Average time-to-first-token in milliseconds; null when no sample reported one. */
|
|
82
|
+
ttftMs: number | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decay threshold for model_perf running sums: once a model accumulates this
|
|
87
|
+
* many samples, each new sample first halves every aggregate, turning the
|
|
88
|
+
* plain average into a recency-weighted one (provider speeds drift over time).
|
|
89
|
+
*/
|
|
90
|
+
const MODEL_PERF_DECAY_AT = 256;
|
|
91
|
+
/** meta-table marker set once historical stats.db rows have been imported into model_perf. */
|
|
92
|
+
const MODEL_PERF_BACKFILL_KEY = "model_perf_backfill";
|
|
93
|
+
/** Batch window for deferred model_perf writes; matches prompt-history's drain cadence. */
|
|
94
|
+
const MODEL_PERF_FLUSH_DELAY_MS = 100;
|
|
95
|
+
/** Backfill ignores stats.db history older than this; decay makes stale provider speeds worthless anyway. */
|
|
96
|
+
const MODEL_PERF_BACKFILL_MAX_AGE_MS = 90 * 86_400_000;
|
|
97
|
+
/** Rows fetched per synchronous backfill chunk — keeps per-chunk event-loop blocking under ~20ms even on cold I/O. */
|
|
98
|
+
const MODEL_PERF_BACKFILL_CHUNK = 2048;
|
|
99
|
+
/** Hard ceiling on rows scanned per backfill run, whatever the age cutoff admits — bounds total CPU on very high-volume databases (models only seen earlier than the newest N measurable rows get no backfill). */
|
|
100
|
+
const MODEL_PERF_BACKFILL_MAX_ROWS = 250_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validates one request timing and shapes it for the model_perf upsert.
|
|
104
|
+
* Returns null for unmeasurable samples (no tokens, no duration). Out-of-range
|
|
105
|
+
* TTFT (>= duration) is bogus latency data; the sample still measures throughput.
|
|
106
|
+
*/
|
|
107
|
+
function normalizeModelPerfSample(modelKey: string, sample: ModelPerfSample): ModelPerfInsert | null {
|
|
108
|
+
const { outputTokens, durationMs } = sample;
|
|
109
|
+
if (!Number.isFinite(outputTokens) || outputTokens <= 0) return null;
|
|
110
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) return null;
|
|
111
|
+
const ttftMs =
|
|
112
|
+
sample.ttftMs !== undefined && Number.isFinite(sample.ttftMs) && sample.ttftMs > 0 && sample.ttftMs < durationMs
|
|
113
|
+
? sample.ttftMs
|
|
114
|
+
: undefined;
|
|
115
|
+
return { modelKey, outputTokens, durationMs, ttftSamples: ttftMs !== undefined ? 1 : 0, ttftMs: ttftMs ?? 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Current agent.db schema version; bump when schema changes require migration. */
|
|
119
|
+
export const SCHEMA_VERSION = 6;
|
|
28
120
|
const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
|
|
29
121
|
|
|
30
122
|
/** Singleton instances per database path */
|
|
@@ -42,9 +134,18 @@ export class AgentStorage {
|
|
|
42
134
|
#listSettingsStmt: Statement;
|
|
43
135
|
#upsertModelUsageStmt: Statement;
|
|
44
136
|
#listModelUsageStmt: Statement;
|
|
137
|
+
#upsertModelPerfStmt: Statement;
|
|
138
|
+
#listModelPerfStmt: Statement;
|
|
45
139
|
#modelUsageCache: string[] | null = null;
|
|
140
|
+
/** Only the real user db auto-imports stats.db history; custom paths (tests, embedding) opt in explicitly. */
|
|
141
|
+
#autoPerfBackfill: boolean;
|
|
142
|
+
/** One backfill *check* per process; the persistent gate is the meta marker. */
|
|
143
|
+
#perfBackfillChecked = false;
|
|
144
|
+
/** Coalesces per-turn perf samples into one deferred transaction off the turn's hot path. */
|
|
145
|
+
#perfDrain = new AsyncDrain<ModelPerfInsert>(MODEL_PERF_FLUSH_DELAY_MS);
|
|
46
146
|
|
|
47
147
|
private constructor(dbPath: string) {
|
|
148
|
+
this.#autoPerfBackfill = dbPath === getAgentDbPath();
|
|
48
149
|
this.#ensureDir(dbPath);
|
|
49
150
|
try {
|
|
50
151
|
this.#db = new Database(dbPath);
|
|
@@ -72,6 +173,22 @@ export class AgentStorage {
|
|
|
72
173
|
this.#listModelUsageStmt = this.#db.prepare(
|
|
73
174
|
"SELECT model_key, last_used_at FROM model_usage ORDER BY last_used_at DESC",
|
|
74
175
|
);
|
|
176
|
+
// Recency-weighted upsert: past MODEL_PERF_DECAY_AT samples, every new
|
|
177
|
+
// sample first halves the aggregates so old measurements fade out.
|
|
178
|
+
this.#upsertModelPerfStmt = this.#db.prepare(
|
|
179
|
+
`INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at)
|
|
180
|
+
VALUES (?1, 1, ?2, ?3, ?4, ?5, ${SQLITE_NOW_EPOCH})
|
|
181
|
+
ON CONFLICT(model_key) DO UPDATE SET
|
|
182
|
+
samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.samples / 2 ELSE model_perf.samples END) + 1,
|
|
183
|
+
output_tokens = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.output_tokens * 0.5 ELSE model_perf.output_tokens END) + excluded.output_tokens,
|
|
184
|
+
gen_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.gen_ms * 0.5 ELSE model_perf.gen_ms END) + excluded.gen_ms,
|
|
185
|
+
ttft_samples = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_samples * 0.5 ELSE model_perf.ttft_samples END) + excluded.ttft_samples,
|
|
186
|
+
ttft_ms = (CASE WHEN model_perf.samples >= ${MODEL_PERF_DECAY_AT} THEN model_perf.ttft_ms * 0.5 ELSE model_perf.ttft_ms END) + excluded.ttft_ms,
|
|
187
|
+
updated_at = ${SQLITE_NOW_EPOCH}`,
|
|
188
|
+
);
|
|
189
|
+
this.#listModelPerfStmt = this.#db.prepare(
|
|
190
|
+
"SELECT model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms FROM model_perf",
|
|
191
|
+
);
|
|
75
192
|
}
|
|
76
193
|
|
|
77
194
|
/**
|
|
@@ -93,6 +210,21 @@ CREATE TABLE IF NOT EXISTS model_usage (
|
|
|
93
210
|
last_used_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
94
211
|
);
|
|
95
212
|
|
|
213
|
+
CREATE TABLE IF NOT EXISTS model_perf (
|
|
214
|
+
model_key TEXT PRIMARY KEY,
|
|
215
|
+
samples REAL NOT NULL DEFAULT 0,
|
|
216
|
+
output_tokens REAL NOT NULL DEFAULT 0,
|
|
217
|
+
gen_ms REAL NOT NULL DEFAULT 0,
|
|
218
|
+
ttft_samples REAL NOT NULL DEFAULT 0,
|
|
219
|
+
ttft_ms REAL NOT NULL DEFAULT 0,
|
|
220
|
+
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
224
|
+
key TEXT PRIMARY KEY,
|
|
225
|
+
value TEXT NOT NULL
|
|
226
|
+
);
|
|
227
|
+
|
|
96
228
|
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
|
97
229
|
`);
|
|
98
230
|
|
|
@@ -175,6 +307,14 @@ CREATE TABLE settings (
|
|
|
175
307
|
if (fromVersion < 5) {
|
|
176
308
|
this.#migrateSchemaV4ToV5();
|
|
177
309
|
}
|
|
310
|
+
if (fromVersion < 6) {
|
|
311
|
+
// v5 → v6: TPS switched from the post-TTFT decode window to total
|
|
312
|
+
// request duration (hidden reasoning made decode-window rates bogus).
|
|
313
|
+
// Purge the old aggregates and re-arm the stats.db backfill so
|
|
314
|
+
// history is re-imported through the corrected fold.
|
|
315
|
+
this.#db.run("DELETE FROM model_perf");
|
|
316
|
+
this.#db.prepare("DELETE FROM meta WHERE key = ?").run(MODEL_PERF_BACKFILL_KEY);
|
|
317
|
+
}
|
|
178
318
|
}
|
|
179
319
|
|
|
180
320
|
#migrateSchemaV4ToV5(): void {
|
|
@@ -257,6 +397,8 @@ FROM model_usage_legacy
|
|
|
257
397
|
this.#listSettingsStmt.finalize();
|
|
258
398
|
this.#upsertModelUsageStmt.finalize();
|
|
259
399
|
this.#listModelUsageStmt.finalize();
|
|
400
|
+
this.#upsertModelPerfStmt.finalize();
|
|
401
|
+
this.#listModelPerfStmt.finalize();
|
|
260
402
|
// SqliteAuthCredentialStore.close() finalizes its own statements and
|
|
261
403
|
// closes the shared #db handle — must run after our statements finalize.
|
|
262
404
|
this.#authStore.close();
|
|
@@ -317,6 +459,191 @@ FROM model_usage_legacy
|
|
|
317
459
|
}
|
|
318
460
|
}
|
|
319
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Folds one completed request's timing into the model's perf aggregates.
|
|
464
|
+
* TPS is measured over the total request duration — not the post-TTFT
|
|
465
|
+
* decode window, which undercounts generation time (and so inflates the
|
|
466
|
+
* rate) when reasoning tokens are generated before the first visible
|
|
467
|
+
* token. Invalid samples (no tokens, no duration) are dropped.
|
|
468
|
+
*
|
|
469
|
+
* Deferred like prompt history: samples are batched and written in one
|
|
470
|
+
* transaction after {@link MODEL_PERF_FLUSH_DELAY_MS}, keeping SQLite off
|
|
471
|
+
* the turn-completion hot path. Fire-and-forget safe — flush failures are
|
|
472
|
+
* logged, never thrown; await the returned promise only to observe the flush.
|
|
473
|
+
* @param modelKey - Model key in "provider/modelId" format
|
|
474
|
+
*/
|
|
475
|
+
recordModelPerf(modelKey: string, sample: ModelPerfSample): Promise<void> {
|
|
476
|
+
const row = normalizeModelPerfSample(modelKey, sample);
|
|
477
|
+
if (!row) return Promise.resolve();
|
|
478
|
+
return this.#perfDrain.push(row, rows => this.#flushModelPerf(rows));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#flushModelPerf(rows: ModelPerfInsert[]): void {
|
|
482
|
+
// Kick the one-time history import too, so aggregates populate even if
|
|
483
|
+
// the user never opens /models. Additive merge makes ordering with live
|
|
484
|
+
// samples irrelevant.
|
|
485
|
+
this.#kickModelPerfBackfill();
|
|
486
|
+
try {
|
|
487
|
+
this.#db.transaction((batch: ModelPerfInsert[]) => {
|
|
488
|
+
for (const row of batch) this.#foldModelPerf(row);
|
|
489
|
+
})(rows);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logger.warn("AgentStorage failed to record model perf", { error: String(error) });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#foldModelPerf(row: ModelPerfInsert): void {
|
|
496
|
+
this.#upsertModelPerfStmt.run(row.modelKey, row.outputTokens, row.durationMs, row.ttftSamples, row.ttftMs);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Returns recency-weighted TPS/TTFT averages for every model with recorded
|
|
501
|
+
* requests, keyed by "provider/modelId". Read by the /models browser.
|
|
502
|
+
* Also kicks the one-time background stats.db import; until it completes,
|
|
503
|
+
* models without live samples are simply absent.
|
|
504
|
+
*/
|
|
505
|
+
getModelPerf(): Map<string, ModelPerfStats> {
|
|
506
|
+
this.#kickModelPerfBackfill();
|
|
507
|
+
const stats = new Map<string, ModelPerfStats>();
|
|
508
|
+
try {
|
|
509
|
+
for (const row of this.#listModelPerfStmt.all() as ModelPerfRow[]) {
|
|
510
|
+
if (row.gen_ms <= 0 || row.output_tokens <= 0) continue;
|
|
511
|
+
stats.set(row.model_key, {
|
|
512
|
+
samples: row.samples,
|
|
513
|
+
tps: (row.output_tokens * 1000) / row.gen_ms,
|
|
514
|
+
ttftMs: row.ttft_samples > 0 ? row.ttft_ms / row.ttft_samples : null,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
} catch (error) {
|
|
518
|
+
logger.warn("AgentStorage failed to read model perf", { error: String(error) });
|
|
519
|
+
}
|
|
520
|
+
return stats;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* One-time, non-blocking import of historical request timings from the
|
|
525
|
+
* `omp stats` database (`~/.omp/stats.db`) into model_perf. Fire-and-forget:
|
|
526
|
+
* the walk runs in bounded chunks with event-loop yields between them
|
|
527
|
+
* (bun:sqlite is synchronous — an unbounded scan here froze the TUI for
|
|
528
|
+
* ~30s on multi-million-row stats databases), and the persistent meta
|
|
529
|
+
* marker is only set on success so a crash or error retries next process.
|
|
530
|
+
* A missing stats.db leaves the marker unset so a later `omp stats` run
|
|
531
|
+
* still gets imported. No-op for non-default db paths.
|
|
532
|
+
*/
|
|
533
|
+
#kickModelPerfBackfill(): void {
|
|
534
|
+
if (!this.#autoPerfBackfill || this.#perfBackfillChecked) return;
|
|
535
|
+
this.#perfBackfillChecked = true;
|
|
536
|
+
try {
|
|
537
|
+
const marker = this.#db.prepare("SELECT value FROM meta WHERE key = ?").get(MODEL_PERF_BACKFILL_KEY);
|
|
538
|
+
if (marker) return;
|
|
539
|
+
const statsDbPath = getStatsDbPath();
|
|
540
|
+
if (!fs.existsSync(statsDbPath)) return;
|
|
541
|
+
void this.backfillModelPerfFromStats(statsDbPath)
|
|
542
|
+
.then(imported => {
|
|
543
|
+
this.#db
|
|
544
|
+
.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")
|
|
545
|
+
.run(MODEL_PERF_BACKFILL_KEY, "complete");
|
|
546
|
+
logger.info("AgentStorage imported model perf history from stats.db", { imported });
|
|
547
|
+
})
|
|
548
|
+
.catch(error => {
|
|
549
|
+
logger.warn("AgentStorage model perf backfill failed", { error: String(error) });
|
|
550
|
+
});
|
|
551
|
+
} catch (error) {
|
|
552
|
+
logger.warn("AgentStorage model perf backfill failed", { error: String(error) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Imports recent measurable request rows from an `omp stats` database
|
|
558
|
+
* (`messages` table) into the model_perf aggregates. Walks newest-first
|
|
559
|
+
* over the timestamp index in {@link MODEL_PERF_BACKFILL_CHUNK}-row chunks,
|
|
560
|
+
* yielding to the event loop between chunks, and keeps at most
|
|
561
|
+
* {@link MODEL_PERF_DECAY_AT} rows per model within the
|
|
562
|
+
* {@link MODEL_PERF_BACKFILL_MAX_AGE_MS} window — beyond either bound the
|
|
563
|
+
* live decay would erase the contribution anyway. Errored turns are
|
|
564
|
+
* excluded; aborted turns with reported usage count, matching live capture.
|
|
565
|
+
* Sums land in one additive transaction at the end, so concurrent live
|
|
566
|
+
* samples merge correctly regardless of order.
|
|
567
|
+
* @param statsDbPath - Path to a stats.db file; opened read-only
|
|
568
|
+
* @returns Number of rows folded in
|
|
569
|
+
* @throws When the stats db cannot be opened or queried
|
|
570
|
+
*/
|
|
571
|
+
async backfillModelPerfFromStats(statsDbPath: string): Promise<number> {
|
|
572
|
+
const statsDb = new Database(statsDbPath, { readonly: true });
|
|
573
|
+
try {
|
|
574
|
+
statsDb.run("PRAGMA busy_timeout = 5000");
|
|
575
|
+
const select = statsDb.prepare(
|
|
576
|
+
`SELECT rowid, timestamp, provider, model, output_tokens, duration, ttft
|
|
577
|
+
FROM messages
|
|
578
|
+
WHERE (timestamp < ?1 OR (timestamp = ?1 AND rowid < ?2))
|
|
579
|
+
AND timestamp >= ?3
|
|
580
|
+
AND duration > 0 AND output_tokens > 0 AND stop_reason != 'error'
|
|
581
|
+
ORDER BY timestamp DESC, rowid DESC
|
|
582
|
+
LIMIT ?4`,
|
|
583
|
+
);
|
|
584
|
+
const cutoff = Date.now() - MODEL_PERF_BACKFILL_MAX_AGE_MS;
|
|
585
|
+
const sums = new Map<string, PerfAccum>();
|
|
586
|
+
let cursorTimestamp = Number.MAX_SAFE_INTEGER;
|
|
587
|
+
let cursorRowid = Number.MAX_SAFE_INTEGER;
|
|
588
|
+
let scanned = 0;
|
|
589
|
+
let imported = 0;
|
|
590
|
+
while (scanned < MODEL_PERF_BACKFILL_MAX_ROWS) {
|
|
591
|
+
const chunk = Math.min(MODEL_PERF_BACKFILL_CHUNK, MODEL_PERF_BACKFILL_MAX_ROWS - scanned);
|
|
592
|
+
const rows = select.all(cursorTimestamp, cursorRowid, cutoff, chunk) as StatsMessageRow[];
|
|
593
|
+
if (rows.length === 0) break;
|
|
594
|
+
scanned += rows.length;
|
|
595
|
+
const last = rows[rows.length - 1];
|
|
596
|
+
cursorTimestamp = last.timestamp;
|
|
597
|
+
cursorRowid = last.rowid;
|
|
598
|
+
for (const row of rows) {
|
|
599
|
+
const key = `${row.provider}/${row.model}`;
|
|
600
|
+
let accum = sums.get(key);
|
|
601
|
+
if (accum && accum.samples >= MODEL_PERF_DECAY_AT) continue;
|
|
602
|
+
const normalized = normalizeModelPerfSample(key, {
|
|
603
|
+
outputTokens: row.output_tokens,
|
|
604
|
+
durationMs: row.duration,
|
|
605
|
+
ttftMs: row.ttft ?? undefined,
|
|
606
|
+
});
|
|
607
|
+
if (!normalized) continue;
|
|
608
|
+
if (!accum) {
|
|
609
|
+
accum = { samples: 0, outputTokens: 0, genMs: 0, ttftSamples: 0, ttftMs: 0 };
|
|
610
|
+
sums.set(key, accum);
|
|
611
|
+
}
|
|
612
|
+
accum.samples += 1;
|
|
613
|
+
accum.outputTokens += normalized.outputTokens;
|
|
614
|
+
accum.genMs += normalized.durationMs;
|
|
615
|
+
accum.ttftSamples += normalized.ttftSamples;
|
|
616
|
+
accum.ttftMs += normalized.ttftMs;
|
|
617
|
+
imported++;
|
|
618
|
+
}
|
|
619
|
+
if (rows.length < chunk) break;
|
|
620
|
+
// Yield so a chunked walk never freezes the TUI (bun:sqlite is sync).
|
|
621
|
+
await Bun.sleep(0);
|
|
622
|
+
}
|
|
623
|
+
if (sums.size > 0) {
|
|
624
|
+
const upsert = this.#db.prepare(
|
|
625
|
+
`INSERT INTO model_perf (model_key, samples, output_tokens, gen_ms, ttft_samples, ttft_ms, updated_at)
|
|
626
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ${SQLITE_NOW_EPOCH})
|
|
627
|
+
ON CONFLICT(model_key) DO UPDATE SET
|
|
628
|
+
samples = model_perf.samples + excluded.samples,
|
|
629
|
+
output_tokens = model_perf.output_tokens + excluded.output_tokens,
|
|
630
|
+
gen_ms = model_perf.gen_ms + excluded.gen_ms,
|
|
631
|
+
ttft_samples = model_perf.ttft_samples + excluded.ttft_samples,
|
|
632
|
+
ttft_ms = model_perf.ttft_ms + excluded.ttft_ms,
|
|
633
|
+
updated_at = ${SQLITE_NOW_EPOCH}`,
|
|
634
|
+
);
|
|
635
|
+
this.#db.transaction(() => {
|
|
636
|
+
for (const [key, accum] of sums) {
|
|
637
|
+
upsert.run(key, accum.samples, accum.outputTokens, accum.genMs, accum.ttftSamples, accum.ttftMs);
|
|
638
|
+
}
|
|
639
|
+
})();
|
|
640
|
+
}
|
|
641
|
+
return imported;
|
|
642
|
+
} finally {
|
|
643
|
+
statsDb.close();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
320
647
|
/**
|
|
321
648
|
* Checks if any auth credentials exist in storage.
|
|
322
649
|
* @returns True if at least one credential is stored
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database, type Statement } from "bun:sqlite";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { AsyncDrain, getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
|
|
6
6
|
export interface HistoryEntry {
|
|
7
7
|
id: number;
|
|
@@ -28,39 +28,6 @@ function escapeLikePattern(text: string): string {
|
|
|
28
28
|
return text.replace(/[\\%_]/g, "\\$&");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
class AsyncDrain<T> {
|
|
32
|
-
#queue?: T[];
|
|
33
|
-
#promise = Promise.resolve();
|
|
34
|
-
|
|
35
|
-
constructor(readonly delayMs: number = 0) {}
|
|
36
|
-
|
|
37
|
-
push(value: T, hnd: (values: T[]) => Promise<void> | void): Promise<void> {
|
|
38
|
-
let queue = this.#queue;
|
|
39
|
-
if (!queue) {
|
|
40
|
-
this.#queue = queue = [];
|
|
41
|
-
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
42
|
-
const exec = (): void => {
|
|
43
|
-
try {
|
|
44
|
-
if (this.#queue === queue) {
|
|
45
|
-
this.#queue = undefined;
|
|
46
|
-
}
|
|
47
|
-
resolve(hnd(queue!));
|
|
48
|
-
} catch (error) {
|
|
49
|
-
reject(error);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
if (this.delayMs > 0) {
|
|
53
|
-
setTimeout(exec, this.delayMs);
|
|
54
|
-
} else {
|
|
55
|
-
queueMicrotask(exec);
|
|
56
|
-
}
|
|
57
|
-
this.#promise = promise;
|
|
58
|
-
}
|
|
59
|
-
queue.push(value);
|
|
60
|
-
return this.#promise;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
31
|
export class HistoryStorage {
|
|
65
32
|
#db: Database;
|
|
66
33
|
static #instance?: HistoryStorage;
|
|
@@ -326,6 +326,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
326
326
|
if (prompt) return { prompt };
|
|
327
327
|
},
|
|
328
328
|
},
|
|
329
|
+
{
|
|
330
|
+
name: "queue",
|
|
331
|
+
description: "Queue a message for after the agent yields",
|
|
332
|
+
inlineHint: "<message>",
|
|
333
|
+
allowArgs: true,
|
|
334
|
+
handleTui: async (command, runtime) => {
|
|
335
|
+
await runtime.ctx.handleQueueCommand(command.args);
|
|
336
|
+
},
|
|
337
|
+
},
|
|
329
338
|
{
|
|
330
339
|
name: "model",
|
|
331
340
|
aliases: ["models"],
|