@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8

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 (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3271 -3224
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +14 -1
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/components/welcome.d.ts +4 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  13. package/dist/types/modes/interactive-mode.d.ts +2 -0
  14. package/dist/types/modes/queue-input.d.ts +8 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/session/agent-storage.d.ts +57 -0
  17. package/package.json +12 -12
  18. package/scripts/build-binary.ts +0 -1
  19. package/scripts/compile-binary.ts +4 -3
  20. package/src/cli/bench-cli.ts +7 -26
  21. package/src/cli/usage-cli.ts +11 -0
  22. package/src/commands/usage.ts +13 -2
  23. package/src/config/settings-schema.ts +1 -1
  24. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  25. package/src/modes/components/advisor-config.ts +3 -1
  26. package/src/modes/components/custom-editor.test.ts +58 -1
  27. package/src/modes/components/custom-editor.ts +42 -11
  28. package/src/modes/components/model-browser.ts +154 -60
  29. package/src/modes/components/model-hub.ts +475 -122
  30. package/src/modes/components/plan-review-overlay.ts +7 -0
  31. package/src/modes/components/tips.txt +2 -1
  32. package/src/modes/components/usage-row.ts +5 -6
  33. package/src/modes/components/welcome.ts +13 -14
  34. package/src/modes/controllers/input-controller.ts +140 -6
  35. package/src/modes/controllers/selector-controller.ts +20 -13
  36. package/src/modes/controllers/todo-command-controller.ts +1 -2
  37. package/src/modes/interactive-mode.ts +18 -0
  38. package/src/modes/queue-input.ts +132 -0
  39. package/src/modes/types.ts +2 -0
  40. package/src/modes/utils/ui-helpers.ts +19 -20
  41. package/src/session/agent-session.ts +184 -48
  42. package/src/session/agent-storage.ts +330 -3
  43. package/src/session/history-storage.ts +1 -34
  44. package/src/slash-commands/builtin-registry.ts +9 -0
  45. package/src/web/search/providers/perplexity.ts +18 -2
@@ -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
- /** Bump when schema changes require migration */
27
- const SCHEMA_VERSION = 5;
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"],
@@ -581,11 +581,27 @@ async function callPerplexityAsk(
581
581
  search_recency_filter: params.search_recency_filter ?? null,
582
582
  is_incognito: true,
583
583
  use_schematized_api: true,
584
- skip_search_enabled: true,
584
+ // `true` (the native app's default) lets the backend classifier skip
585
+ // retrieval for queries it deems answerable from memory — the model then
586
+ // runs ungrounded and refuses with "I don't currently have live access".
587
+ // We are a search tool; always retrieve.
588
+ skip_search_enabled: false,
589
+ // Belt and braces with `skip_search_enabled: false`: the web client sets
590
+ // this to force retrieval even when the skip classifier fires.
591
+ always_search_override: true,
592
+ prompt_source: "user",
593
+ source: "default",
594
+ local_search_enabled: false,
595
+ // Declare no tool-approval UI and no local (Comet) browser agent, so the
596
+ // stream never stalls waiting for a confirmation we cannot render.
597
+ should_ask_for_mcp_tool_confirmation: false,
598
+ supports_tool_approval_modal: false,
599
+ force_enable_browser_agent: false,
600
+ is_local_browser_available: false,
601
+ is_local_browser_allowed: false,
585
602
  };
586
603
  if (auth.type === "anonymous") {
587
604
  requestParams.send_back_text_in_streaming_api = true;
588
- requestParams.source = "default";
589
605
  }
590
606
 
591
607
  const response = await (params.fetch ?? fetch)(PERPLEXITY_OAUTH_ASK_URL, {