akm-cli 0.1.3 → 0.2.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/dist/db.js CHANGED
@@ -4,9 +4,11 @@ import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { cosineSimilarity } from "./embedder";
6
6
  import { getDbPath } from "./paths";
7
+ import { buildSearchFields } from "./search-fields";
8
+ import { ensureUsageEventsSchema } from "./usage-events";
7
9
  import { warn } from "./warn";
8
10
  // ── Constants ───────────────────────────────────────────────────────────────
9
- export const DB_VERSION = 6;
11
+ export const DB_VERSION = 8;
10
12
  export const EMBEDDING_DIM = 384;
11
13
  // ── Database lifecycle ──────────────────────────────────────────────────────
12
14
  export function openDatabase(dbPath, options) {
@@ -83,6 +85,8 @@ function ensureSchema(db, embeddingDim) {
83
85
  // Check stored version — if it differs from DB_VERSION, drop and recreate all tables
84
86
  const storedVersion = getMeta(db, "version");
85
87
  if (storedVersion && storedVersion !== String(DB_VERSION)) {
88
+ db.exec("DROP TABLE IF EXISTS utility_scores");
89
+ db.exec("DROP TABLE IF EXISTS usage_events");
86
90
  db.exec("DROP TABLE IF EXISTS embeddings");
87
91
  db.exec("DROP TABLE IF EXISTS entries_vec");
88
92
  db.exec("DROP TABLE IF EXISTS entries_fts");
@@ -120,17 +124,35 @@ function ensureSchema(db, embeddingDim) {
120
124
  FOREIGN KEY (id) REFERENCES entries(id)
121
125
  );
122
126
  `);
123
- // FTS5 table — standalone with explicit entry_id for joining
127
+ // FTS5 table — multi-column with per-field weighting via bm25()
124
128
  const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
125
129
  if (!ftsExists) {
126
130
  db.exec(`
127
131
  CREATE VIRTUAL TABLE entries_fts USING fts5(
128
132
  entry_id UNINDEXED,
129
- search_text,
133
+ name,
134
+ description,
135
+ tags,
136
+ hints,
137
+ content,
130
138
  tokenize='porter unicode61'
131
139
  );
132
140
  `);
133
141
  }
142
+ // Usage events table — created by ensureUsageEventsSchema() at runtime.
143
+ // Utility scores table (aggregated per-entry utility metrics)
144
+ db.exec(`
145
+ CREATE TABLE IF NOT EXISTS utility_scores (
146
+ entry_id INTEGER PRIMARY KEY,
147
+ utility REAL NOT NULL DEFAULT 0,
148
+ show_count INTEGER NOT NULL DEFAULT 0,
149
+ search_count INTEGER NOT NULL DEFAULT 0,
150
+ select_rate REAL NOT NULL DEFAULT 0,
151
+ last_used_at TEXT,
152
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
153
+ FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
154
+ );
155
+ `);
134
156
  // sqlite-vec table
135
157
  if (isVecAvailable(db)) {
136
158
  // Check if stored embedding dimension differs from configured one
@@ -142,7 +164,7 @@ function ensureSchema(db, embeddingDim) {
142
164
  catch {
143
165
  /* ignore */
144
166
  }
145
- // CR-2: Delete stale BLOB embeddings so they don't produce silently wrong
167
+ // Delete stale BLOB embeddings so they don't produce silently wrong
146
168
  // similarity scores against the new-dimension vec table.
147
169
  try {
148
170
  db.exec("DELETE FROM embeddings");
@@ -165,6 +187,8 @@ function ensureSchema(db, embeddingDim) {
165
187
  }
166
188
  setMeta(db, "embeddingDim", String(embeddingDim));
167
189
  }
190
+ // Usage telemetry table
191
+ ensureUsageEventsSchema(db);
168
192
  }
169
193
  // ── Meta helpers ────────────────────────────────────────────────────────────
170
194
  export function getMeta(db, key) {
@@ -231,7 +255,7 @@ function deleteRelatedRows(db, ids) {
231
255
  catch {
232
256
  /* ignore */
233
257
  }
234
- // HI-1: Also delete from FTS table so orphaned FTS rows don't remain
258
+ // Also delete from FTS table so orphaned FTS rows don't remain
235
259
  try {
236
260
  db.prepare(`DELETE FROM entries_fts WHERE entry_id IN (${placeholders})`).run(...chunk);
237
261
  }
@@ -246,16 +270,48 @@ function deleteRelatedRows(db, ids) {
246
270
  /* ignore */
247
271
  }
248
272
  }
273
+ // Clean up utility scores before deleting entries
274
+ try {
275
+ db.prepare(`DELETE FROM utility_scores WHERE entry_id IN (${placeholders})`).run(...chunk);
276
+ }
277
+ catch {
278
+ /* ignore */
279
+ }
280
+ // Clean up usage events before deleting entries
281
+ try {
282
+ db.prepare(`DELETE FROM usage_events WHERE entry_id IN (${placeholders})`).run(...chunk);
283
+ }
284
+ catch {
285
+ /* ignore */
286
+ }
249
287
  }
250
288
  }
251
289
  export function rebuildFts(db) {
252
- // CR-1: Wrap DELETE + INSERT in a single transaction so the FTS table is
290
+ // Wrap DELETE + INSERT in a single transaction so the FTS table is
253
291
  // never left empty between the two statements if a crash occurs.
254
- // HI-14: Store the integer id directly (FTS5 stores all content as text
292
+ // Store the integer id directly (FTS5 stores all content as text
255
293
  // internally; the join in searchFts compares numerically without CAST).
294
+ //
295
+ // Insert into separate FTS5 columns by extracting per-field text from
296
+ // the entry_json using buildSearchFields(). The entries.search_text column
297
+ // is kept as a concatenated fallback for embedding generation.
256
298
  db.transaction(() => {
257
299
  db.exec("DELETE FROM entries_fts");
258
- db.exec("INSERT INTO entries_fts (entry_id, search_text) SELECT id, search_text FROM entries");
300
+ const rows = db.prepare("SELECT id, entry_json FROM entries").all();
301
+ const insertStmt = db.prepare("INSERT INTO entries_fts (entry_id, name, description, tags, hints, content) VALUES (?, ?, ?, ?, ?, ?)");
302
+ for (const row of rows) {
303
+ let entry;
304
+ let fields;
305
+ try {
306
+ entry = JSON.parse(row.entry_json);
307
+ fields = buildSearchFields(entry);
308
+ }
309
+ catch {
310
+ warn(`[db] rebuildFts: skipping entry id=${row.id} — invalid entry_json`);
311
+ continue;
312
+ }
313
+ insertStmt.run(row.id, fields.name, fields.description, fields.tags, fields.hints, fields.content);
314
+ }
259
315
  })();
260
316
  }
261
317
  // ── Vector operations ───────────────────────────────────────────────────────
@@ -284,8 +340,8 @@ export function searchVec(db, queryEmbedding, k) {
284
340
  .all(buf, k);
285
341
  }
286
342
  catch (err) {
287
- // MD-5: Log the failure so it's visible in diagnostics
288
- console.warn("[db] searchVec (sqlite-vec path) failed:", err instanceof Error ? err.message : String(err));
343
+ // Log the failure so it's visible in diagnostics
344
+ warn("[db] searchVec (sqlite-vec path) failed:", err instanceof Error ? err.message : String(err));
289
345
  return [];
290
346
  }
291
347
  }
@@ -321,7 +377,7 @@ function searchBlobVec(db, queryEmbedding, k) {
321
377
  }
322
378
  catch (err) {
323
379
  // MD-5: Log the failure so it's visible in diagnostics
324
- console.warn("[db] searchBlobVec (JS fallback) failed:", err instanceof Error ? err.message : String(err));
380
+ warn("[db] searchBlobVec (JS fallback) failed:", err instanceof Error ? err.message : String(err));
325
381
  return [];
326
382
  }
327
383
  }
@@ -330,13 +386,49 @@ export function searchFts(db, query, limit, entryType) {
330
386
  const ftsQuery = sanitizeFtsQuery(query);
331
387
  if (!ftsQuery)
332
388
  return [];
389
+ // Try the exact AND query first
390
+ const exactResults = runFtsQuery(db, ftsQuery, limit, entryType);
391
+ if (exactResults.length > 0)
392
+ return exactResults;
393
+ // Exact match returned zero results — try prefix fallback.
394
+ // Append FTS5 `*` suffix to each token that is >= 3 characters long.
395
+ // Short tokens (1-2 chars) are excluded from prefix expansion because
396
+ // they produce too many false positives.
397
+ const prefixQuery = buildPrefixQuery(ftsQuery);
398
+ if (!prefixQuery)
399
+ return [];
400
+ return runFtsQuery(db, prefixQuery, limit, entryType);
401
+ }
402
+ /**
403
+ * Build a prefix query from an FTS5 query string by appending `*` to each
404
+ * token that is 3+ characters long. Tokens shorter than 3 characters are
405
+ * kept as-is (no prefix expansion) to avoid overly broad matches.
406
+ *
407
+ * Returns null if no tokens qualify for prefix expansion.
408
+ */
409
+ function buildPrefixQuery(ftsQuery) {
410
+ const tokens = ftsQuery.split(/\s+/).filter(Boolean);
411
+ let hasPrefix = false;
412
+ const prefixTokens = tokens.map((t) => {
413
+ if (t.length >= 3) {
414
+ hasPrefix = true;
415
+ return `${t}*`;
416
+ }
417
+ return t;
418
+ });
419
+ if (!hasPrefix)
420
+ return null;
421
+ return prefixTokens.join(" ");
422
+ }
423
+ function runFtsQuery(db, ftsQuery, limit, entryType) {
333
424
  let sql;
334
425
  let params;
335
- // HI-14: Join on integer entry_id directly (no CAST needed; we store integer)
426
+ // Join on integer entry_id directly (no CAST needed; we store integer)
427
+ // Use bm25() with per-column weights: entry_id(0), name(10), description(5), tags(3), hints(2), content(1)
336
428
  if (entryType && entryType !== "any") {
337
429
  sql = `
338
430
  SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
339
- bm25(entries_fts) AS bm25Score
431
+ bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
340
432
  FROM entries_fts f
341
433
  JOIN entries e ON e.id = f.entry_id
342
434
  WHERE entries_fts MATCH ?
@@ -349,7 +441,7 @@ export function searchFts(db, query, limit, entryType) {
349
441
  else {
350
442
  sql = `
351
443
  SELECT e.id, e.file_path AS filePath, e.entry_json, e.search_text AS searchText,
352
- bm25(entries_fts) AS bm25Score
444
+ bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
353
445
  FROM entries_fts f
354
446
  JOIN entries e ON e.id = f.entry_id
355
447
  WHERE entries_fts MATCH ?
@@ -360,7 +452,7 @@ export function searchFts(db, query, limit, entryType) {
360
452
  }
361
453
  try {
362
454
  const rows = db.prepare(sql).all(...params);
363
- // CR-6: Guard against corrupt JSON — skip the row rather than crashing
455
+ // Guard against corrupt JSON — skip the row rather than crashing
364
456
  const results = [];
365
457
  for (const row of rows) {
366
458
  let entry;
@@ -368,7 +460,7 @@ export function searchFts(db, query, limit, entryType) {
368
460
  entry = JSON.parse(row.entry_json);
369
461
  }
370
462
  catch {
371
- console.warn(`[db] searchFts: skipping entry id=${row.id} — corrupt entry_json`);
463
+ warn(`[db] searchFts: skipping entry id=${row.id} — corrupt entry_json`);
372
464
  continue;
373
465
  }
374
466
  results.push({
@@ -416,7 +508,7 @@ export function getAllEntries(db, entryType) {
416
508
  params = [];
417
509
  }
418
510
  const rows = db.prepare(sql).all(...params);
419
- // CR-6: Guard against corrupt JSON — skip the row rather than crashing
511
+ // Guard against corrupt JSON — skip the row rather than crashing
420
512
  const entries = [];
421
513
  for (const row of rows) {
422
514
  let entry;
@@ -424,7 +516,7 @@ export function getAllEntries(db, entryType) {
424
516
  entry = JSON.parse(row.entry_json);
425
517
  }
426
518
  catch {
427
- console.warn(`[db] getAllEntries: skipping entry id=${row.id} — corrupt entry_json`);
519
+ warn(`[db] getAllEntries: skipping entry id=${row.id} — corrupt entry_json`);
428
520
  continue;
429
521
  }
430
522
  entries.push({
@@ -447,13 +539,13 @@ export function getEntryById(db, id) {
447
539
  const row = db.prepare("SELECT file_path, entry_json FROM entries WHERE id = ?").get(id);
448
540
  if (!row)
449
541
  return undefined;
450
- // CR-6: Guard against corrupt JSON
542
+ // Guard against corrupt JSON
451
543
  let entry;
452
544
  try {
453
545
  entry = JSON.parse(row.entry_json);
454
546
  }
455
547
  catch {
456
- console.warn(`[db] getEntryById: skipping entry id=${id} — corrupt entry_json`);
548
+ warn(`[db] getEntryById: skipping entry id=${id} — corrupt entry_json`);
457
549
  return undefined;
458
550
  }
459
551
  return { filePath: row.file_path, entry };
@@ -462,7 +554,7 @@ export function getEntriesByDir(db, dirPath) {
462
554
  const rows = db
463
555
  .prepare("SELECT id, entry_key, dir_path, file_path, stash_dir, entry_json, search_text FROM entries WHERE dir_path = ?")
464
556
  .all(dirPath);
465
- // CR-6: Guard against corrupt JSON — skip the row rather than crashing
557
+ // Guard against corrupt JSON — skip the row rather than crashing
466
558
  const entries = [];
467
559
  for (const row of rows) {
468
560
  let entry;
@@ -470,7 +562,7 @@ export function getEntriesByDir(db, dirPath) {
470
562
  entry = JSON.parse(row.entry_json);
471
563
  }
472
564
  catch {
473
- console.warn(`[db] getEntriesByDir: skipping entry id=${row.id} — corrupt entry_json`);
565
+ warn(`[db] getEntriesByDir: skipping entry id=${row.id} — corrupt entry_json`);
474
566
  continue;
475
567
  }
476
568
  entries.push({
@@ -485,3 +577,67 @@ export function getEntriesByDir(db, dirPath) {
485
577
  }
486
578
  return entries;
487
579
  }
580
+ /**
581
+ * Get the utility score for an entry, or undefined if none exists.
582
+ */
583
+ export function getUtilityScore(db, entryId) {
584
+ const row = db
585
+ .prepare("SELECT entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at FROM utility_scores WHERE entry_id = ?")
586
+ .get(entryId);
587
+ if (!row)
588
+ return undefined;
589
+ return {
590
+ entryId: row.entry_id,
591
+ utility: row.utility,
592
+ showCount: row.show_count,
593
+ searchCount: row.search_count,
594
+ selectRate: row.select_rate,
595
+ lastUsedAt: row.last_used_at ?? undefined,
596
+ updatedAt: row.updated_at,
597
+ };
598
+ }
599
+ /**
600
+ * Batch-load utility scores for multiple entry IDs in a single query.
601
+ * Returns a Map keyed by entry_id for O(1) lookup.
602
+ */
603
+ export function getUtilityScoresByIds(db, ids) {
604
+ if (ids.length === 0)
605
+ return new Map();
606
+ const result = new Map();
607
+ // Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
608
+ for (let i = 0; i < ids.length; i += SQLITE_CHUNK_SIZE) {
609
+ const chunk = ids.slice(i, i + SQLITE_CHUNK_SIZE);
610
+ const placeholders = chunk.map(() => "?").join(",");
611
+ const rows = db
612
+ .prepare(`SELECT entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at FROM utility_scores WHERE entry_id IN (${placeholders})`)
613
+ .all(...chunk);
614
+ for (const row of rows) {
615
+ result.set(row.entry_id, {
616
+ entryId: row.entry_id,
617
+ utility: row.utility,
618
+ showCount: row.show_count,
619
+ searchCount: row.search_count,
620
+ selectRate: row.select_rate,
621
+ lastUsedAt: row.last_used_at ?? undefined,
622
+ updatedAt: row.updated_at,
623
+ });
624
+ }
625
+ }
626
+ return result;
627
+ }
628
+ /**
629
+ * Insert or update a utility score for an entry.
630
+ */
631
+ export function upsertUtilityScore(db, entryId, data) {
632
+ db.prepare(`
633
+ INSERT INTO utility_scores (entry_id, utility, show_count, search_count, select_rate, last_used_at, updated_at)
634
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
635
+ ON CONFLICT(entry_id) DO UPDATE SET
636
+ utility = excluded.utility,
637
+ show_count = excluded.show_count,
638
+ search_count = excluded.search_count,
639
+ select_rate = excluded.select_rate,
640
+ last_used_at = excluded.last_used_at,
641
+ updated_at = datetime('now')
642
+ `).run(entryId, data.utility, data.showCount, data.searchCount, data.selectRate, data.lastUsedAt ?? null);
643
+ }
package/dist/embedder.js CHANGED
@@ -1,10 +1,34 @@
1
1
  import { fetchWithTimeout } from "./common";
2
2
  import { warn } from "./warn";
3
+ // ── Default local model ─────────────────────────────────────────────────────
4
+ /**
5
+ * Default local transformer model for embeddings.
6
+ * `bge-small-en-v1.5` scores higher on MTEB benchmarks than the previous
7
+ * `all-MiniLM-L6-v2` at the same 384-dimension footprint.
8
+ */
9
+ export const DEFAULT_LOCAL_MODEL = "Xenova/bge-small-en-v1.5";
10
+ /**
11
+ * Return the local model name that will be used for embedding.
12
+ * When `overrideModel` is provided it takes precedence; otherwise
13
+ * the default model is returned.
14
+ */
15
+ function getLocalModelName(overrideModel) {
16
+ return overrideModel || DEFAULT_LOCAL_MODEL;
17
+ }
3
18
  // Cache the promise itself (not the resolved result) so concurrent calls share
4
19
  // the same initialisation work and never download the model twice.
20
+ // The cache is keyed by model name so switching models gets a fresh pipeline.
5
21
  let localEmbedderPromise;
6
- async function getLocalEmbedder() {
22
+ let localEmbedderModelName;
23
+ async function getLocalEmbedder(modelName) {
24
+ const resolvedModel = getLocalModelName(modelName);
25
+ // If the cached pipeline was created for a different model, discard it.
26
+ if (localEmbedderPromise && localEmbedderModelName !== resolvedModel) {
27
+ localEmbedderPromise = undefined;
28
+ localEmbedderModelName = undefined;
29
+ }
7
30
  if (!localEmbedderPromise) {
31
+ localEmbedderModelName = resolvedModel;
8
32
  localEmbedderPromise = (async () => {
9
33
  let pipeline;
10
34
  try {
@@ -15,18 +39,23 @@ async function getLocalEmbedder() {
15
39
  throw new Error("Semantic search requires @xenova/transformers. Install it with: npm install @xenova/transformers");
16
40
  }
17
41
  const pipelineFn = pipeline;
18
- return pipelineFn("feature-extraction", "Xenova/all-MiniLM-L6-v2");
42
+ return pipelineFn("feature-extraction", resolvedModel);
19
43
  })();
20
44
  // HI-13: Clear the cached promise on failure so the next call retries
21
45
  // instead of permanently rejecting every subsequent call with the same error.
22
46
  localEmbedderPromise.catch(() => {
23
47
  localEmbedderPromise = undefined;
48
+ localEmbedderModelName = undefined;
24
49
  });
25
50
  }
26
51
  return localEmbedderPromise;
27
52
  }
28
- async function embedLocal(text) {
29
- const model = await getLocalEmbedder();
53
+ export function resetLocalEmbedder() {
54
+ localEmbedderPromise = undefined;
55
+ localEmbedderModelName = undefined;
56
+ }
57
+ async function embedLocal(text, modelName) {
58
+ const model = await getLocalEmbedder(modelName);
30
59
  const result = await model(text, { pooling: "mean", normalize: true });
31
60
  return Array.from(result.data);
32
61
  }
@@ -71,17 +100,68 @@ async function embedRemote(text, config) {
71
100
  }
72
101
  return l2Normalize(json.data[0].embedding);
73
102
  }
103
+ // ── Helpers ──────────────────────────────────────────────────────────────────
104
+ /** Check whether an EmbeddingConnectionConfig has a valid remote endpoint. */
105
+ function hasRemoteEndpoint(config) {
106
+ return !!config.endpoint && (config.endpoint.startsWith("http://") || config.endpoint.startsWith("https://"));
107
+ }
108
+ // ── LRU embedding cache ─────────────────────────────────────────────────────
109
+ // Caches query embeddings to avoid redundant computation for repeated queries.
110
+ // Uses a simple Map with LRU eviction (delete + re-insert to move to end).
111
+ const EMBED_CACHE_MAX = 100;
112
+ const embedCache = new Map();
113
+ /**
114
+ * Build a cache key from query text and optional config.
115
+ * Different endpoints/models should not share cached embeddings.
116
+ * apiKey deliberately excluded: same endpoint+model produce identical embeddings regardless of auth
117
+ */
118
+ function embedCacheKey(text, config) {
119
+ if (!config)
120
+ return `local::${text}`;
121
+ const endpoint = config.endpoint || "";
122
+ const model = config.model || config.localModel || "";
123
+ return `${endpoint}:${model}:${text}`;
124
+ }
125
+ /**
126
+ * Clear the embedding cache. Call when the embedding model changes
127
+ * or when you want to force fresh embeddings.
128
+ */
129
+ export function clearEmbeddingCache() {
130
+ embedCache.clear();
131
+ }
74
132
  // ── Public API ──────────────────────────────────────────────────────────────
75
133
  /**
76
134
  * Generate an embedding for the given text.
77
- * If embeddingConfig is provided, uses the configured OpenAI-compatible endpoint.
78
- * Otherwise falls back to local @xenova/transformers.
135
+ * If embeddingConfig has a remote endpoint, uses the configured OpenAI-compatible endpoint.
136
+ * Otherwise falls back to local @xenova/transformers using the model from
137
+ * `embeddingConfig.localModel` or `DEFAULT_LOCAL_MODEL`.
138
+ *
139
+ * Results are cached in an LRU cache (max ~100 entries) keyed by query text
140
+ * and embedding config. Repeated identical queries return the cached vector.
79
141
  */
80
142
  export async function embed(text, embeddingConfig) {
81
- if (embeddingConfig) {
82
- return embedRemote(text, embeddingConfig);
143
+ const key = embedCacheKey(text, embeddingConfig);
144
+ // Check cache first
145
+ const cached = embedCache.get(key);
146
+ if (cached) {
147
+ // Move to end (most recently used) for LRU ordering
148
+ embedCache.delete(key);
149
+ embedCache.set(key, cached);
150
+ return cached;
151
+ }
152
+ // Compute the embedding
153
+ const result = embeddingConfig && hasRemoteEndpoint(embeddingConfig)
154
+ ? await embedRemote(text, embeddingConfig)
155
+ : await embedLocal(text, embeddingConfig?.localModel);
156
+ // Evict oldest entry if at capacity
157
+ if (embedCache.size >= EMBED_CACHE_MAX) {
158
+ const oldest = embedCache.keys().next().value;
159
+ if (oldest !== undefined) {
160
+ embedCache.delete(oldest);
161
+ }
83
162
  }
84
- return embedLocal(text);
163
+ embedCache.set(key, result);
164
+ return result;
85
165
  }
86
166
  // ── Batch embedding ─────────────────────────────────────────────────────────
87
167
  /**
@@ -92,13 +172,14 @@ export async function embed(text, embeddingConfig) {
92
172
  export async function embedBatch(texts, embeddingConfig) {
93
173
  if (texts.length === 0)
94
174
  return [];
95
- if (embeddingConfig) {
175
+ if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
96
176
  return embedRemoteBatch(texts, embeddingConfig);
97
177
  }
98
178
  // Local transformer: process sequentially (pipeline handles one at a time)
179
+ const localModel = embeddingConfig?.localModel;
99
180
  const results = [];
100
181
  for (const text of texts) {
101
- results.push(await embedLocal(text));
182
+ results.push(await embedLocal(text, localModel));
102
183
  }
103
184
  return results;
104
185
  }
@@ -164,7 +245,7 @@ export function cosineSimilarity(a, b) {
164
245
  }
165
246
  // ── Availability check ──────────────────────────────────────────────────────
166
247
  export async function isEmbeddingAvailable(embeddingConfig) {
167
- if (embeddingConfig) {
248
+ if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
168
249
  try {
169
250
  await embedRemote("test", embeddingConfig);
170
251
  return true;
@@ -174,7 +255,7 @@ export async function isEmbeddingAvailable(embeddingConfig) {
174
255
  }
175
256
  }
176
257
  try {
177
- await getLocalEmbedder();
258
+ await getLocalEmbedder(embeddingConfig?.localModel);
178
259
  return true;
179
260
  }
180
261
  catch {
@@ -69,6 +69,9 @@ const matchers = [];
69
69
  /** Renderer lookup by name. */
70
70
  const renderers = new Map();
71
71
  let builtinsPromise;
72
+ export function resetBuiltinsCache() {
73
+ builtinsPromise = undefined;
74
+ }
72
75
  /**
73
76
  * Ensure that built-in matchers and renderers are registered.
74
77
  * Called lazily on first use of runMatchers/getRenderer.