@oh-my-pi/pi-catalog 18.7.0 → 18.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.0] - 2026-10-07
6
+
7
+ ### Changed
8
+
9
+ - Improved catalog performance by speeding up model cache reads and repeated catalog-wide model builds, especially for large catalogs.
10
+
5
11
  ## [18.7.0] - 2026-10-06
6
12
 
7
13
  ### Added
@@ -1,13 +1,3 @@
1
- /**
2
- * Runtime model-identity taxonomy: classifies wire model ids into
3
- * `(class, family, revision)` ranks, applies reviewed identity overrides, and
4
- * exposes the collapse/discovery vocabularies.
5
- *
6
- * Faithful port of the o2 reference (`taxonomy.rs` / `classify.rs`):
7
- * matchers rank `exact(4) > bounded(3) > namespace(2) > prefix(1) > glob(0)`
8
- * with token byte length as tiebreak; equal cross-class or cross-family ranks
9
- * throw unless classification is `lenient` (discovery normalization).
10
- */
11
1
  import type { Effort } from "../effort.js";
12
2
  import type { ModelIdentity } from "./types.js";
13
3
  /** Two classes, families, or reviewed override patterns tie for one model id. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-catalog",
3
- "version": "18.7.0",
3
+ "version": "18.8.0",
4
4
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
5
5
  "keywords": [
6
6
  "ai",
@@ -93,12 +93,12 @@
93
93
  "gen:proto": "bun scripts/generate-protocols.ts"
94
94
  },
95
95
  "dependencies": {
96
- "@oh-my-pi/omptype": "18.7.0",
97
- "@oh-my-pi/pi-utils": "18.7.0"
96
+ "@oh-my-pi/omptype": "18.8.0",
97
+ "@oh-my-pi/pi-utils": "18.8.0"
98
98
  },
99
99
  "devDependencies": {
100
100
  "@bgotink/kdl": "0.4.0",
101
- "@oh-my-pi/pi-ai": "18.7.0",
101
+ "@oh-my-pi/pi-ai": "18.8.0",
102
102
  "@types/bun": "^1.3.14"
103
103
  },
104
104
  "engines": {
@@ -288,7 +288,12 @@ function collect(winners: WinnerTable, pick: (rule: CompiledRule) => Record<stri
288
288
  return out;
289
289
  }
290
290
 
291
- const resolveCache = new LRUCache<string, ResolvedAxes>({ max: 512 });
291
+ /**
292
+ * Sized above the bundled catalog (~5.6k targets) plus discovered rows so a
293
+ * catalog-wide buildModel pass (discovery, then merge rebuilding the same ids)
294
+ * hits instead of thrashing. Entries share rule values: a few hundred bytes each.
295
+ */
296
+ const resolveCache = new LRUCache<string, ResolvedAxes>({ max: 16384 });
292
297
 
293
298
  function keyPart(value: string | undefined): string {
294
299
  return value === undefined ? "-1:" : `${value.length}:${value}`;
@@ -8,6 +8,7 @@
8
8
  * with token byte length as tiebreak; equal cross-class or cross-family ranks
9
9
  * throw unless classification is `lenient` (discovery normalization).
10
10
  */
11
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
11
12
  import type { Effort } from "../effort";
12
13
  import { globMatch } from "./cascade";
13
14
  import { formatRevision, parseRevisionPrefix } from "./revision";
@@ -433,8 +434,8 @@ export function stripThinkingVariantSuffix(model: string): string | undefined {
433
434
  * @throws AmbiguousIdentityError on equal-rank cross-class, cross-family, or
434
435
  * reviewed-pattern matches unless `opts.lenient`.
435
436
  */
436
- const classifyMemo = new Map<string, ModelIdentity>();
437
- const CLASSIFY_MEMO_MAX = 4096;
437
+ // LRU sized above the bundled catalog so catalog-wide passes stay hot (a full `clear()` thrashed them).
438
+ const classifyMemo = new LRUCache<string, ModelIdentity>({ max: 16384 });
438
439
 
439
440
  function classifyMemoKey(provider: string, modelId: string, lenient: boolean): string {
440
441
  return `${provider.length}:${provider}${modelId.length}:${modelId}${lenient ? 1 : 0}`;
@@ -446,7 +447,6 @@ export function classifyModel(provider: string, modelId: string, opts?: Classify
446
447
  const cached = classifyMemo.get(key);
447
448
  if (cached !== undefined) return { ...cached };
448
449
  const identity = classifyModelUncached(provider, modelId, opts);
449
- if (classifyMemo.size >= CLASSIFY_MEMO_MAX) classifyMemo.clear();
450
450
  classifyMemo.set(key, Object.freeze(identity));
451
451
  return { ...identity };
452
452
  }
@@ -3,6 +3,7 @@
3
3
  * Replaces per-provider JSON files with a single cache.db.
4
4
  */
5
5
  import type { Database } from "bun:sqlite";
6
+ import * as path from "node:path";
6
7
  import { getModelDbPath, isSqliteCorruptionError, logger, openSqliteDatabaseSync, VERSION } from "@oh-my-pi/pi-utils";
7
8
  import RULES from "./compat/rules.json" with { type: "json" };
8
9
  import type { Api, Model } from "./types";
@@ -46,14 +47,19 @@ function materializationPolicy(): string {
46
47
  return cachedMaterializationPolicy;
47
48
  }
48
49
 
49
- interface CacheRow {
50
+ interface CacheRowMeta {
50
51
  provider_id: string;
51
52
  version: number;
52
53
  materialization_policy: string;
53
54
  updated_at: number;
54
55
  authoritative: number;
55
56
  static_fingerprint: string;
56
- models: string;
57
+ /**
58
+ * `Bun.hash` of `models`; empty for rows written before the column existed
59
+ * (never equal). An in-place `UPDATE` of `models` that leaves the hash
60
+ * untouched resets it to empty (`model_cache_models_hash_reset` trigger).
61
+ */
62
+ models_hash: string;
57
63
  header_omitted_model_ids: string;
58
64
  unrestorable_header_model_ids: string;
59
65
  header_restore_version: number;
@@ -63,6 +69,10 @@ interface CacheRow {
63
69
  refresh_authoritative: number | null;
64
70
  }
65
71
 
72
+ interface CacheRow extends CacheRowMeta {
73
+ models: string;
74
+ }
75
+
66
76
  /**
67
77
  * Payload rows are multi-MB and SQLite rewrites a whole record (including its
68
78
  * overflow pages) on any column update. Freshness that advances without a
@@ -71,15 +81,23 @@ interface CacheRow {
71
81
  * the payload row's `updated_at`, so a payload rewritten by another binary
72
82
  * silently invalidates it.
73
83
  */
74
- const SELECT_CACHE_ROW = `
75
- SELECT m.*,
76
- r.payload_updated_at AS refresh_payload_updated_at,
77
- r.updated_at AS refresh_updated_at,
78
- r.authoritative AS refresh_authoritative
84
+ const CACHE_ROW_FROM = `
79
85
  FROM model_cache m
80
86
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
81
87
  WHERE m.provider_id = ?
82
88
  `;
89
+ const CACHE_ROW_REFRESH_COLUMNS = `
90
+ r.payload_updated_at AS refresh_payload_updated_at,
91
+ r.updated_at AS refresh_updated_at,
92
+ r.authoritative AS refresh_authoritative`;
93
+ const SELECT_CACHE_ROW = `SELECT m.*, ${CACHE_ROW_REFRESH_COLUMNS} ${CACHE_ROW_FROM}`;
94
+ /** Scalar columns plus `models_hash`: validates a memoized parse without reading the multi-MB payload. */
95
+ const SELECT_CACHE_ROW_META = `
96
+ SELECT m.provider_id, m.version, m.materialization_policy, m.updated_at, m.authoritative,
97
+ m.static_fingerprint, m.models_hash, m.header_omitted_model_ids,
98
+ m.unrestorable_header_model_ids, m.header_restore_version, ${CACHE_ROW_REFRESH_COLUMNS}
99
+ ${CACHE_ROW_FROM}
100
+ `;
83
101
 
84
102
  /** Cumulative model-cache write counters for this process. */
85
103
  export interface ModelCacheWriteStats {
@@ -149,8 +167,11 @@ let sharedDbPath: string | null = null;
149
167
  interface ReadRowCacheEntry {
150
168
  /** `PRAGMA data_version` of the shared connection when validated; null for per-call connections. */
151
169
  dataVersion: number | null;
152
- /** Raw row the entry was parsed from; null when the row was absent or rejected. */
153
- row: CacheRow | null;
170
+ /**
171
+ * Scalar columns (plus `models_hash`) of the row the entry was parsed from;
172
+ * null when the row was absent or rejected. Never the multi-MB payload itself.
173
+ */
174
+ meta: CacheRowMeta | null;
154
175
  entry: CacheEntry<Api> | null;
155
176
  }
156
177
 
@@ -182,7 +203,7 @@ function withFreshness<TApi extends Api>(
182
203
  }
183
204
  function invalidateReadRow(providerId: string, dbPath?: string): void {
184
205
  try {
185
- readRowCache.delete(readCacheKey(dbPath ?? getModelDbPath(), providerId));
206
+ readRowCache.delete(readCacheKey(resolveCacheDb(dbPath).resolvedPath, providerId));
186
207
  } catch {
187
208
  // Best-effort only; a missed invalidation just costs one extra parse.
188
209
  }
@@ -213,6 +234,7 @@ function initializeDb(db: Database): void {
213
234
  header_omitted_model_ids TEXT NOT NULL DEFAULT '[]',
214
235
  unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]',
215
236
  header_restore_version INTEGER NOT NULL DEFAULT 0,
237
+ models_hash TEXT NOT NULL DEFAULT '',
216
238
  models TEXT NOT NULL
217
239
  )
218
240
  `);
@@ -225,6 +247,17 @@ function initializeDb(db: Database): void {
225
247
  )
226
248
  `);
227
249
  migrateCacheSchema(db);
250
+ // `models_hash` is the only payload-change detector for the read memo and the
251
+ // unchanged-write skip. Writers here replace whole rows; this keeps an
252
+ // in-place edit of `models` by any other tool or binary from reusing a stale hash.
253
+ db.run(`
254
+ CREATE TRIGGER IF NOT EXISTS model_cache_models_hash_reset
255
+ AFTER UPDATE OF models ON model_cache
256
+ WHEN NEW.models_hash = OLD.models_hash
257
+ BEGIN
258
+ UPDATE model_cache SET models_hash = '' WHERE provider_id = NEW.provider_id;
259
+ END
260
+ `);
228
261
  }
229
262
 
230
263
  function closeSharedDb(): void {
@@ -280,8 +313,22 @@ function runModelCacheDb<T>(resolvedPath: string, shared: boolean, useDb: (db: D
280
313
  }
281
314
 
282
315
  function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
283
- const resolvedPath = dbPath ?? getModelDbPath();
284
- return runModelCacheDb(resolvedPath, dbPath === undefined, useDb);
316
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
317
+ return runModelCacheDb(resolvedPath, shared, useDb);
318
+ }
319
+
320
+ /**
321
+ * An explicit path naming the default `models.db` (e.g. the SDK passing
322
+ * `getModelDbPath(agentDir)` for the default agent dir) shares the long-lived
323
+ * handle, so it gets the `data_version` fast path instead of opening,
324
+ * initializing and re-reading the row on every call.
325
+ */
326
+ function resolveCacheDb(dbPath: string | undefined): { resolvedPath: string; shared: boolean } {
327
+ const defaultPath = getModelDbPath();
328
+ if (dbPath === undefined || dbPath === defaultPath || path.resolve(dbPath) === path.resolve(defaultPath)) {
329
+ return { resolvedPath: defaultPath, shared: true };
330
+ }
331
+ return { resolvedPath: dbPath, shared: false };
285
332
  }
286
333
 
287
334
  function migrateCacheSchema(db: Database): void {
@@ -306,6 +353,12 @@ function migrateCacheSchema(db: Database): void {
306
353
  // header matching was introduced.
307
354
  db.run("ALTER TABLE model_cache ADD COLUMN header_restore_version INTEGER NOT NULL DEFAULT 0");
308
355
  }
356
+ if (!columns.some(column => column.name === "models_hash")) {
357
+ // Rows from older binaries keep '' (never equal), so they are re-read in
358
+ // full until the next write records their hash; no version bump needed.
359
+ // Older binaries rewriting a row via INSERT OR REPLACE reset it to ''.
360
+ db.run("ALTER TABLE model_cache ADD COLUMN models_hash TEXT NOT NULL DEFAULT ''");
361
+ }
309
362
  } finally {
310
363
  stmt.finalize();
311
364
  }
@@ -419,15 +472,14 @@ export function readModelCache<TApi extends Api>(
419
472
  dbPath?: string,
420
473
  ): CacheEntry<TApi> | null {
421
474
  try {
422
- const resolvedPath = dbPath ?? getModelDbPath();
423
- const key = readCacheKey(resolvedPath, providerId);
424
475
  // Monotonic change signal: same-shaped WAL overwrites after checkpoint
425
476
  // can leave every size:mtime pair identical, so file metadata alone
426
477
  // cannot invalidate. PRAGMA data_version increments on each committed
427
478
  // write transaction visible to a new reader. It is only comparable
428
479
  // across calls on the same connection, so it gates the no-query fast
429
480
  // path for the shared handle only.
430
- const shared = dbPath === undefined;
481
+ const { resolvedPath, shared } = resolveCacheDb(dbPath);
482
+ const key = readCacheKey(resolvedPath, providerId);
431
483
  return runModelCacheDb(resolvedPath, shared, db => {
432
484
  const dataVersion = shared ? dbDataVersion(db) : null;
433
485
  const cached = readRowCache.get(key);
@@ -436,34 +488,32 @@ export function readModelCache<TApi extends Api>(
436
488
  // cached row's updatedAt so a long-lived process goes stale.
437
489
  return withFreshness(cached.entry as CacheEntry<TApi> | null, ttlMs, now);
438
490
  }
439
- // Any commit (to any provider's row) bumps data_version. Re-read this
440
- // row, but reuse the parsed entry when its bytes are unchanged.
441
- const stmt = db.query<CacheRow, [string]>(SELECT_CACHE_ROW);
442
- let row: CacheRow | null;
443
- try {
444
- row = stmt.get(providerId);
445
- } finally {
446
- stmt.finalize();
491
+ // Any commit (to any provider's row) bumps data_version. Validate the
492
+ // memoized parse against the row's scalar columns and payload hash
493
+ // first; only a changed payload pays for the multi-MB `models` read.
494
+ let entry: CacheEntry<TApi> | null = null;
495
+ let meta: CacheRowMeta | null = null;
496
+ if (cached?.entry && cached.meta) {
497
+ meta = queryRow<CacheRowMeta>(db, SELECT_CACHE_ROW_META, providerId);
498
+ if (meta && meta.materialization_policy === materializationPolicy() && cacheRowsEqual(cached.meta, meta)) {
499
+ // Payload unchanged; only side-table freshness may have moved.
500
+ entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, meta), ttlMs, now);
501
+ } else {
502
+ meta = null;
503
+ }
447
504
  }
448
- let entry: CacheEntry<TApi> | null;
449
- if (
450
- cached?.entry &&
451
- cached.row &&
452
- row &&
453
- row.materialization_policy === materializationPolicy() &&
454
- cacheRowsEqual(cached.row, row)
455
- ) {
456
- // Payload bytes unchanged; only side-table freshness may have moved.
457
- entry = withFreshness(withRowState(cached.entry as CacheEntry<TApi>, row), ttlMs, now);
458
- } else {
505
+ if (entry === null) {
506
+ // One statement reads payload and scalars from the same snapshot.
507
+ const row = queryRow<CacheRow>(db, SELECT_CACHE_ROW, providerId);
459
508
  entry = parseCacheRow<TApi>(db, providerId, row, ttlMs, now);
509
+ if (entry !== null && row !== null) {
510
+ // Drop the payload so the memo never pins the multi-MB `models` string.
511
+ const { models: _models, ...rowScalars } = row;
512
+ meta = rowScalars;
513
+ }
460
514
  }
461
515
  if (readRowCache.size >= READ_ROW_CACHE_MAX) readRowCache.clear();
462
- readRowCache.set(key, {
463
- dataVersion,
464
- row: entry === null ? null : row,
465
- entry: entry as CacheEntry<Api> | null,
466
- });
516
+ readRowCache.set(key, { dataVersion, meta, entry: entry as CacheEntry<Api> | null });
467
517
  return entry;
468
518
  });
469
519
  } catch {
@@ -471,8 +521,20 @@ export function readModelCache<TApi extends Api>(
471
521
  }
472
522
  }
473
523
 
474
- /** Whether two rows carry the same payload record (refresh side-row columns excluded). */
475
- function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
524
+ function queryRow<T>(db: Database, sql: string, providerId: string): T | null {
525
+ const stmt = db.query<T, [string]>(sql);
526
+ try {
527
+ return stmt.get(providerId);
528
+ } finally {
529
+ stmt.finalize();
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Whether two rows carry the same payload record (refresh side-row columns
535
+ * excluded). Payloads compare by `models_hash`; rows without one never match.
536
+ */
537
+ function cacheRowsEqual(left: CacheRowMeta, right: CacheRowMeta): boolean {
476
538
  return (
477
539
  left.version === right.version &&
478
540
  left.materialization_policy === right.materialization_policy &&
@@ -482,7 +544,8 @@ function cacheRowsEqual(left: CacheRow, right: CacheRow): boolean {
482
544
  left.header_omitted_model_ids === right.header_omitted_model_ids &&
483
545
  left.unrestorable_header_model_ids === right.unrestorable_header_model_ids &&
484
546
  left.header_restore_version === right.header_restore_version &&
485
- left.models === right.models
547
+ left.models_hash !== "" &&
548
+ left.models_hash === right.models_hash
486
549
  );
487
550
  }
488
551
 
@@ -498,7 +561,7 @@ function rowState(row: StoredRowState): { updatedAt: number; authoritative: bool
498
561
  return { updatedAt: row.updated_at, authoritative: row.authoritative === 1 };
499
562
  }
500
563
 
501
- function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: CacheRow): CacheEntry<TApi> {
564
+ function withRowState<TApi extends Api>(entry: CacheEntry<TApi>, row: StoredRowState): CacheEntry<TApi> {
502
565
  const { updatedAt, authoritative } = rowState(row);
503
566
  return entry.updatedAt === updatedAt && entry.authoritative === authoritative
504
567
  ? entry
@@ -648,6 +711,8 @@ export function writeModelCache<TApi extends Api>(
648
711
  const serializedHeaderOmitted = JSON.stringify(headerOmittedModelIds);
649
712
  const serializedUnrestorable = JSON.stringify(unrestorableHeaderModelIds);
650
713
  const serializedModels = JSON.stringify(cachedModels);
714
+ // Length prefix: a collision would also need an equal payload size.
715
+ const modelsHash = `${serializedModels.length.toString(36)}:${Bun.hash(serializedModels).toString(36)}`;
651
716
  const outcome = db
652
717
  .transaction((): "skipped" | "refreshed" | "written" => {
653
718
  const matchStmt = db.prepare<
@@ -662,7 +727,7 @@ export function writeModelCache<TApi extends Api>(
662
727
  LEFT JOIN model_cache_refresh r ON r.provider_id = m.provider_id
663
728
  WHERE m.provider_id = ? AND m.version = ? AND m.materialization_policy = ?
664
729
  AND m.static_fingerprint = ? AND m.header_omitted_model_ids = ?
665
- AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models = ?`,
730
+ AND m.unrestorable_header_model_ids = ? AND m.header_restore_version = ? AND m.models_hash = ?`,
666
731
  );
667
732
  let stored: StoredRowState | null;
668
733
  try {
@@ -674,7 +739,7 @@ export function writeModelCache<TApi extends Api>(
674
739
  serializedHeaderOmitted,
675
740
  serializedUnrestorable,
676
741
  HEADER_RESTORE_VERSION,
677
- serializedModels,
742
+ modelsHash,
678
743
  );
679
744
  } finally {
680
745
  matchStmt.finalize();
@@ -697,8 +762,8 @@ export function writeModelCache<TApi extends Api>(
697
762
  `INSERT OR REPLACE INTO model_cache (
698
763
  provider_id, version, materialization_policy, updated_at, authoritative, static_fingerprint,
699
764
  header_omitted_model_ids, unrestorable_header_model_ids,
700
- header_restore_version, models
701
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
765
+ header_restore_version, models_hash, models
766
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
702
767
  [
703
768
  providerId,
704
769
  CACHE_SCHEMA_VERSION,
@@ -709,6 +774,7 @@ export function writeModelCache<TApi extends Api>(
709
774
  serializedHeaderOmitted,
710
775
  serializedUnrestorable,
711
776
  HEADER_RESTORE_VERSION,
777
+ modelsHash,
712
778
  serializedModels,
713
779
  ],
714
780
  );