From e79b2919b6b1d209efb46e9b71fc56a7653224f8 Mon Sep 17 00:00:00 2001 From: "Robert Kent Jr." Date: Tue, 11 Aug 2026 16:53:57 -0400 Subject: [PATCH 1/6] feat(search): make the embedding model and device configurable `getDb` called `transformersJs()` with no arguments, pinning every index and query to retriv's smallest default (bge-small-en-v1.5, 384d) on whatever device transformers.js chose, which is the CPU under Node. Neither was reachable through config, a flag, or an env var. That default thins out as skills accumulate: search builds one sqlite-vec DB per package and pools scores across all of them at query time, so cross-corpus ranking depends directly on embedding quality. Adds `embedModel` and `embedDevice` config keys, matching entries in `skilld config`, and `SKILLD_EMBED_MODEL` / `SKILLD_EMBED_DEVICE` overrides for single runs. Precedence is env, then config, then default. Both defaults are unchanged: `bge-small-en-v1.5`, and a device of `auto` that resolves to undefined so the option is omitted entirely. Device measurements on an Apple M5 Max, 120 documents, best of 3 (docs/sec): model cpu coreml webgpu bge-small-en-v1.5 664 198 1713 bge-base-en-v1.5 198 68 580 Xenova/bge-large-en-v1.5 71 9 201 webgpu is 2.6-2.9x faster than cpu at every size, 4.4x end to end through the index pipeline; coreml is consistently slower. The ranking is hardware-specific, so the device is offered rather than defaulted, and the picker leads with that caveat. Two correctness details: The bge-large entry pins the full repo id `Xenova/bge-large-en-v1.5`. retriv's bare `bge-large-en-v1.5` preset maps to `onnx-community/bge-large-en-v1.5`, whose weights return 401, so selecting it would fail at first index. The embedding cache keyed vectors by text hash and validated only dimensions. That was safe while the model was fixed; selecting one makes it reachable, since bge-large-en-v1.5 and bge-m3 are both 1024d. Switching kept every cached vector and served one model's embeddings against another's queries. No crash, just silently wrong ranking, with the correct answer dropping out of the top 3 on a 43-document corpus. Cache identity is now `@`, cleared on change, because the same model on a different backend can differ numerically. --- README.md | 51 ++++++++ src/commands/config.ts | 90 ++++++++++++++ src/core/config.ts | 12 ++ src/retriv/embedding-cache.ts | 23 +++- src/retriv/index.ts | 16 ++- src/retriv/models.ts | 122 +++++++++++++++++++ test/unit/embed-models.test.ts | 134 +++++++++++++++++++++ test/unit/embedding-cache-identity.test.ts | 103 ++++++++++++++++ 8 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 src/retriv/models.ts create mode 100644 test/unit/embed-models.test.ts create mode 100644 test/unit/embedding-cache-identity.test.ts diff --git a/README.md b/README.md index f93ebc15..b69911c5 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,57 @@ Generation runs locally: free, offline, no API key. Unlike the CLI and API backe The large default context can exceed memory for big models on constrained hardware (Ollama returns a 500). Lower `OLLAMA_NUM_CTX` or pick a smaller model if generation fails to load. +### Embedding Model + +`skilld search` is powered by a local embedding model. It runs offline through transformers.js — no API key, and no network traffic after the first download. Pick one under **Embedding model** in `skilld config`: + +| Model | Dimensions | Notes | +|-------|-----------:|-------| +| `bge-small-en-v1.5` | 384 | Default. Fastest to index, smallest download. | +| `bge-base-en-v1.5` | 768 | Balanced accuracy and speed. | +| `bge-m3` | 1024 | Multilingual, 8192-token context. | + +Larger models retrieve more accurately but cost more time and memory when indexing. Set `SKILLD_EMBED_MODEL` to override the saved setting for a single run: + +```bash +SKILLD_EMBED_MODEL=bge-m3 skilld add npm:vue +``` + +Search indexes store fixed-width vectors, so changing to a model with different dimensions strands existing indexes. Rebuild them after switching: + +```bash +skilld update --force +``` + +### Embedding Device + +The embedding model runs on the CPU by default. **Embedding device** in `skilld config` moves it onto a GPU backend, which can be substantially faster: + +| Device | Notes | +|--------|-------| +| `auto` | Default. Lets transformers.js choose — CPU under Node. | +| `cpu` | Always available, predictable. | +| `webgpu` | Fastest on Apple Silicon in testing. | +| `coreml` | Apple Neural Engine. Measured slower than CPU for these models. | + +Measured on an Apple M5 Max, 120 documents, best of 3 after warm-up (docs/sec): + +| Model | `cpu` | `coreml` | `webgpu` | +|-------|------:|---------:|---------:| +| `bge-small-en-v1.5` | 664 | 198 | **1713** | +| `bge-base-en-v1.5` | 198 | 68 | **580** | +| `Xenova/bge-large-en-v1.5` | 71 | 9 | **201** | + +WebGPU was 2.6-2.9x faster than CPU at every size, which means `bge-large` on WebGPU indexes faster than `bge-base` does on CPU — better retrieval for less wall-clock. CoreML was consistently slower. + +The ranking is hardware-specific, so benchmark before trusting a device on other machines. Override for a single run with `SKILLD_EMBED_DEVICE`: + +```bash +SKILLD_EMBED_DEVICE=cpu skilld update --force +``` + +If a backend is unavailable, indexing fails to start — switch back to `auto`. + ### Eject Export a skill as a portable, self-contained directory for sharing via git repos: diff --git a/src/commands/config.ts b/src/commands/config.ts index 20d76282..bb7b8e43 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,6 +11,7 @@ import { guard, menuLoop } from '../cli/menu.ts' import { NO_MODELS_MESSAGE, OAUTH_NOTE, pickModel } from '../cli/model-picker.ts' import { defaultFeatures, readConfig, updateConfig } from '../core/config.ts' import { getProjectState } from '../core/skills.ts' +import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, getEmbedModelInfo, resolveEmbedModel } from '../retriv/models.ts' export async function configCommand(): Promise { const initConfig = readConfig() @@ -46,8 +47,15 @@ export async function configCommand(): Promise { const oauthHint = connectedOAuth > 0 ? `${connectedOAuth} connected` : 'none' options.push({ label: 'OAuth providers', value: 'oauth', hint: `${oauthHint} · ⚠ may violate provider ToS` }) } + const embedModel = resolveEmbedModel(config.embedModel) + const embedHint = features.search + ? `${embedModel} · local model powering skilld search` + : `${embedModel} · search is disabled in Data sources` + const embedDevice = config.embedDevice || DEFAULT_EMBED_DEVICE options.push( { label: 'Enhancement model', value: 'model', hint: `${modelHint} · rewrites SKILL.md with best practices` }, + { label: 'Embedding model', value: 'embedModel', hint: embedHint }, + { label: 'Embedding device', value: 'embedDevice', hint: `${embedDevice} · where the embedding model runs` }, { label: 'Target agent', value: 'agent', hint: `${config.agent || 'auto-detect'} · where skills are installed` }, ) return options @@ -92,6 +100,16 @@ export async function configCommand(): Promise { break } + case 'embedModel': { + await configureEmbedModel() + break + } + + case 'embedDevice': { + await configureEmbedDevice() + break + } + case 'agent': { const config = readConfig() const agentChoice = guard(await p.select({ @@ -230,6 +248,78 @@ async function configureModel(): Promise { } } +// ── Embedding model selection ──────────────────────────────────────── + +async function configureEmbedModel(): Promise { + const config = readConfig() + const current = resolveEmbedModel(config.embedModel) + const envOverride = process.env.SKILLD_EMBED_MODEL?.trim() + + if (envOverride) { + p.log.warn(`SKILLD_EMBED_MODEL is set to ${envOverride} and overrides this setting for the current shell.`) + } + + const choice = guard(await p.select({ + message: 'Embedding model — indexes and queries docs for skilld search', + options: EMBED_MODELS.map(m => ({ + label: m.label, + value: m.id, + hint: `${m.dimensions}d · ${m.hint}`, + })), + initialValue: current, + })) + + if (choice === config.embedModel || (choice === DEFAULT_EMBED_MODEL && !config.embedModel)) { + p.log.info(`Embedding model unchanged (${choice})`) + return + } + + const previous = getEmbedModelInfo(current) + const next = getEmbedModelInfo(choice as string) + updateConfig({ embedModel: choice === DEFAULT_EMBED_MODEL ? undefined : choice as string }) + p.log.success(`Embedding model set to ${choice}`) + + // sqlite-vec columns are fixed-width, so a dimension change strands existing + // indexes: they stay queryable at the old width but new docs cannot join them. + if (previous && next && previous.dimensions !== next.dimensions) { + p.log.warn( + `Vector width changed ${previous.dimensions}d → ${next.dimensions}d. ` + + 'Existing search indexes must be rebuilt: skilld update --force', + ) + } +} + +// ── Embedding device selection ─────────────────────────────────────── + +async function configureEmbedDevice(): Promise { + const config = readConfig() + const current = config.embedDevice || DEFAULT_EMBED_DEVICE + const envOverride = process.env.SKILLD_EMBED_DEVICE?.trim() + + if (envOverride) + p.log.warn(`SKILLD_EMBED_DEVICE is set to ${envOverride} and overrides this setting for the current shell.`) + + p.note( + 'The fastest backend depends on your hardware. On an Apple M5 Max, WebGPU\n' + + 'ran 2.6-2.9x faster than CPU across every model size, while CoreML ran\n' + + '3-8x slower. Benchmark before trusting a device on other machines.', + 'Choosing a device', + ) + + const choice = guard(await p.select({ + message: 'Embedding device — where the model runs', + options: EMBED_DEVICES.map(d => ({ label: d.label, value: d.id, hint: d.hint })), + initialValue: current, + })) + + updateConfig({ embedDevice: choice === DEFAULT_EMBED_DEVICE ? undefined : choice as string }) + p.log.success(`Embedding device set to ${choice}`) + + if (choice !== DEFAULT_EMBED_DEVICE && choice !== 'cpu') { + p.log.info('If indexing fails to start, the backend is unavailable on this machine — switch back to Auto.') + } +} + export const configCommandDef = defineCommand({ meta: { name: 'config', description: 'Edit settings' }, args: {}, diff --git a/src/core/config.ts b/src/core/config.ts index 98093c11..d59c861c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -32,6 +32,10 @@ export function getActiveFeatures(overrides?: Partial): Features export interface SkilldConfig { model?: OptimizeModel agent?: string + /** Local embedding model used to build and query the search index */ + embedModel?: string + /** Execution device for the embedding model (auto, cpu, webgpu, coreml) */ + embedDevice?: string features?: FeaturesConfig projects?: string[] skipLlm?: boolean @@ -102,6 +106,10 @@ export function readConfig(): SkilldConfig { config.model = value as OptimizeModel if (key === 'agent' && value) config.agent = value + if (key === 'embedModel' && value) + config.embedModel = value + if (key === 'embedDevice' && value) + config.embedDevice = value if (key === 'skipLlm') config.skipLlm = value === 'true' } @@ -122,6 +130,10 @@ export function writeConfig(config: SkilldConfig): void { yaml += `model: ${config.model}\n` if (config.agent) yaml += `agent: ${config.agent}\n` + if (config.embedModel) + yaml += `embedModel: ${config.embedModel}\n` + if (config.embedDevice) + yaml += `embedDevice: ${config.embedDevice}\n` if (config.skipLlm) yaml += `skipLlm: true\n` if (config.features) { diff --git a/src/retriv/embedding-cache.ts b/src/retriv/embedding-cache.ts index 041ac1fd..126d3dde 100644 --- a/src/retriv/embedding-cache.ts +++ b/src/retriv/embedding-cache.ts @@ -50,7 +50,16 @@ function createSqliteStorage(db: DatabaseSync) { } } -export async function cachedEmbeddings(config: EmbeddingConfig): Promise { +/** + * Wrap an embedding provider with the on-disk vector cache. + * + * `model` identifies which embedder produced the cached vectors. Entries are + * keyed by text hash alone, so vectors from a different model would be served + * for the same text — two models of equal width (bge-large and + * qwen3-embedding:0.6b are both 1024d) would silently mix embedding spaces and + * destroy ranking. Dimensions alone cannot catch that; the model id can. + */ +export async function cachedEmbeddings(config: EmbeddingConfig, model?: string): Promise { const { cachedEmbeddings: retrivCached } = await import('retriv/embeddings/cached') const db = await openDb() const storage = createSqliteStorage(db) @@ -63,10 +72,18 @@ export async function cachedEmbeddings(config: EmbeddingConfig): Promise) { throw new SearchDepsUnavailableError(err) throw err } - const embeddings = await cachedEmbeddings(transformersJs()) + const userConfig = readConfig() + const embedModel = resolveEmbedModel(userConfig.embedModel) + const device = resolveEmbedDevice(userConfig.embedDevice) + // Cache identity pairs model with device: cached vectors are only valid for + // the embedder that produced them, and backends can differ numerically. + const embeddings = await cachedEmbeddings( + transformersJs({ + model: embedModel, + // Omitted when `auto` so transformers.js keeps its own device resolution. + ...(device ? { device } : {}), + }), + `${embedModel}@${device ?? 'auto'}`, + ) return createRetriv({ driver: sqliteMod.default({ path: config.dbPath, diff --git a/src/retriv/models.ts b/src/retriv/models.ts new file mode 100644 index 00000000..ee2d7eda --- /dev/null +++ b/src/retriv/models.ts @@ -0,0 +1,122 @@ +/** + * Local embedding models available to the search index. + * + * Every model here runs offline through transformers.js — no API key, no + * network after the initial download. Larger models retrieve more accurately + * but cost more time and memory to index with. + * + * Dimensions are fixed per model and sqlite-vec columns are fixed-width, so + * switching model invalidates existing indexes. Rebuild with + * `skilld update --force`. + */ +export interface EmbedModelInfo { + /** Model id passed to retriv (resolved to a Hugging Face repo internally) */ + id: string + label: string + /** Vector width — determines index layout */ + dimensions: number + hint: string +} + +export const DEFAULT_EMBED_MODEL = 'bge-small-en-v1.5' + +export const EMBED_MODELS: readonly EmbedModelInfo[] = [ + { + id: 'bge-small-en-v1.5', + label: 'BGE small (English)', + dimensions: 384, + hint: 'fastest to index, smallest download', + }, + { + id: 'bge-base-en-v1.5', + label: 'BGE base (English)', + dimensions: 768, + hint: 'balanced accuracy and speed', + }, + { + // Pinned to the full repo id on purpose: retriv's `bge-large-en-v1.5` + // preset maps to `onnx-community/bge-large-en-v1.5`, which returns 401. + // The Xenova repo carries the same weights and resolves correctly. + id: 'Xenova/bge-large-en-v1.5', + label: 'BGE large (English)', + dimensions: 1024, + hint: 'most accurate English retrieval, slowest to index', + }, + { + id: 'bge-m3', + label: 'BGE m3 (multilingual)', + dimensions: 1024, + hint: 'multilingual, 8192-token context', + }, +] + +export function getEmbedModelInfo(id: string): EmbedModelInfo | undefined { + return EMBED_MODELS.find(m => m.id === id) +} + +/** + * Resolve the embedding model to index and query with. + * + * `SKILLD_EMBED_MODEL` wins so a single run can be overridden without touching + * saved config; otherwise the configured value, otherwise the default. + */ +export function resolveEmbedModel(configured?: string): string { + const fromEnv = process.env.SKILLD_EMBED_MODEL?.trim() + if (fromEnv) + return fromEnv + return configured || DEFAULT_EMBED_MODEL +} + +/** + * Execution device for the embedding model. + * + * `auto` means "let transformers.js decide", which resolves to CPU under Node. + * Everything else is opt-in because the fastest backend is hardware-specific: + * on an Apple M5 Max `webgpu` measured 2.6-2.9x faster than CPU across every + * bge size, while `coreml` measured 3-8x slower (it falls back to CPU for + * unsupported ops and pays for graph partitioning). + */ +export interface EmbedDeviceInfo { + id: string + label: string + hint: string +} + +export const DEFAULT_EMBED_DEVICE = 'auto' + +export const EMBED_DEVICES: readonly EmbedDeviceInfo[] = [ + { + id: 'auto', + label: 'Auto', + hint: 'let transformers.js choose — CPU under Node', + }, + { + id: 'cpu', + label: 'CPU', + hint: 'always available, predictable', + }, + { + id: 'webgpu', + label: 'GPU (WebGPU)', + hint: 'fastest on Apple Silicon in testing — verify on your hardware', + }, + { + id: 'coreml', + label: 'CoreML', + hint: 'Apple Neural Engine — measured slower than CPU for these models', + }, +] + +export function getEmbedDeviceInfo(id: string): EmbedDeviceInfo | undefined { + return EMBED_DEVICES.find(d => d.id === id) +} + +/** + * Resolve the execution device. Returns `undefined` for `auto` so the option + * is omitted entirely and transformers.js keeps its own default resolution. + */ +export function resolveEmbedDevice(configured?: string): string | undefined { + const fromEnv = process.env.SKILLD_EMBED_DEVICE?.trim() + const value = fromEnv || configured || DEFAULT_EMBED_DEVICE + return value === DEFAULT_EMBED_DEVICE ? undefined : value +} diff --git a/test/unit/embed-models.test.ts b/test/unit/embed-models.test.ts new file mode 100644 index 00000000..88a64786 --- /dev/null +++ b/test/unit/embed-models.test.ts @@ -0,0 +1,134 @@ +import { getModelDimensions, resolveModelForPreset } from 'retriv/embeddings/model-info' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, getEmbedDeviceInfo, getEmbedModelInfo, resolveEmbedDevice, resolveEmbedModel } from '../../src/retriv/models.ts' + +describe('resolveEmbedModel', () => { + let original: string | undefined + + beforeEach(() => { + original = process.env.SKILLD_EMBED_MODEL + delete process.env.SKILLD_EMBED_MODEL + }) + + afterEach(() => { + if (original === undefined) + delete process.env.SKILLD_EMBED_MODEL + else + process.env.SKILLD_EMBED_MODEL = original + }) + + it('falls back to the default when nothing is configured', () => { + expect(resolveEmbedModel(undefined)).toBe(DEFAULT_EMBED_MODEL) + }) + + it('uses the configured model', () => { + expect(resolveEmbedModel('bge-base-en-v1.5')).toBe('bge-base-en-v1.5') + }) + + it('lets the env var override configured and default', () => { + process.env.SKILLD_EMBED_MODEL = 'bge-m3' + expect(resolveEmbedModel('bge-base-en-v1.5')).toBe('bge-m3') + expect(resolveEmbedModel(undefined)).toBe('bge-m3') + }) + + it('ignores a blank env var', () => { + process.env.SKILLD_EMBED_MODEL = ' ' + expect(resolveEmbedModel('bge-base-en-v1.5')).toBe('bge-base-en-v1.5') + }) +}) + +describe('embed model registry', () => { + it('includes the default model', () => { + expect(EMBED_MODELS.map(m => m.id)).toContain(DEFAULT_EMBED_MODEL) + }) + + it('has no duplicate ids', () => { + const ids = EMBED_MODELS.map(m => m.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('looks up known models and rejects unknown ones', () => { + expect(getEmbedModelInfo(DEFAULT_EMBED_MODEL)?.dimensions).toBe(384) + expect(getEmbedModelInfo('not-a-model')).toBeUndefined() + }) + + // retriv's bare `bge-large-en-v1.5` preset maps to + // `onnx-community/bge-large-en-v1.5`, whose weights return 401. We pin the + // Xenova repo instead, so the bare id must never creep back in. + it('avoids the bge-large preset that resolves to unavailable weights', () => { + const ids = EMBED_MODELS.map(m => m.id) + expect(ids).not.toContain('bge-large-en-v1.5') + expect(ids).toContain('Xenova/bge-large-en-v1.5') + }) + + // Guards against drift: every id must resolve to a real transformers.js repo + // and our declared width must match retriv's registry, since the declared + // width is what warns users about rebuilding indexes. + it('matches retriv model resolution and dimensions', () => { + for (const model of EMBED_MODELS) { + const resolved = resolveModelForPreset(model.id, 'transformers.js') + expect(resolved, `${model.id} should resolve`).toBeTruthy() + expect(resolved, `${model.id} should map to a namespaced repo`).toContain('/') + expect(getModelDimensions(model.id), `${model.id} dimensions`).toBe(model.dimensions) + } + }) +}) + +describe('resolveEmbedDevice', () => { + let original: string | undefined + + beforeEach(() => { + original = process.env.SKILLD_EMBED_DEVICE + delete process.env.SKILLD_EMBED_DEVICE + }) + + afterEach(() => { + if (original === undefined) + delete process.env.SKILLD_EMBED_DEVICE + else + process.env.SKILLD_EMBED_DEVICE = original + }) + + // `auto` must resolve to undefined so the option is omitted entirely and + // transformers.js keeps its own device resolution. + it('returns undefined for auto so the option is omitted', () => { + expect(resolveEmbedDevice(undefined)).toBeUndefined() + expect(resolveEmbedDevice(DEFAULT_EMBED_DEVICE)).toBeUndefined() + }) + + it('returns the configured device', () => { + expect(resolveEmbedDevice('webgpu')).toBe('webgpu') + }) + + it('lets the env var override configured and default', () => { + process.env.SKILLD_EMBED_DEVICE = 'cpu' + expect(resolveEmbedDevice('webgpu')).toBe('cpu') + expect(resolveEmbedDevice(undefined)).toBe('cpu') + }) + + it('ignores a blank env var', () => { + process.env.SKILLD_EMBED_DEVICE = ' ' + expect(resolveEmbedDevice('webgpu')).toBe('webgpu') + }) + + it('treats an env var of auto as unset', () => { + process.env.SKILLD_EMBED_DEVICE = 'auto' + expect(resolveEmbedDevice('webgpu')).toBeUndefined() + }) +}) + +describe('embed device registry', () => { + it('includes the default device', () => { + expect(EMBED_DEVICES.map(d => d.id)).toContain(DEFAULT_EMBED_DEVICE) + }) + + it('has no duplicate ids', () => { + const ids = EMBED_DEVICES.map(d => d.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('looks up known devices and rejects unknown ones', () => { + expect(getEmbedDeviceInfo('webgpu')?.label).toBe('GPU (WebGPU)') + expect(getEmbedDeviceInfo('not-a-device')).toBeUndefined() + }) +}) diff --git a/test/unit/embedding-cache-identity.test.ts b/test/unit/embedding-cache-identity.test.ts new file mode 100644 index 00000000..00b6c569 --- /dev/null +++ b/test/unit/embedding-cache-identity.test.ts @@ -0,0 +1,103 @@ +import { DatabaseSync } from 'node:sqlite' +import { describe, expect, it } from 'vitest' + +/** + * Guards the cache-invalidation rule in `src/retriv/embedding-cache.ts`. + * + * Vectors are keyed by text hash alone, so the only thing preventing one + * model's vectors being served to another is the stored identity. Dimensions + * are not enough: `Xenova/bge-large-en-v1.5` and `ollama:qwen3-embedding:0.6b` + * are both 1024d, so switching between them would silently mix embedding + * spaces and wreck ranking. + * + * This reimplements the decision against an in-memory database so the rule is + * pinned without touching the user's real cache. + */ +function applyIdentity(db: DatabaseSync, dimensions: number, model?: string): void { + const get = db.prepare('SELECT value FROM meta WHERE key = ?') + const set = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)') + + const storedDims = get.get('dimensions') as { value: string } | undefined + const storedModel = get.get('model') as { value: string } | undefined + const dimsChanged = storedDims && Number(storedDims.value) !== dimensions + const modelChanged = model !== undefined && storedModel?.value !== model + + if (dimsChanged || modelChanged) + db.exec('DELETE FROM embeddings') + + set.run('dimensions', String(dimensions)) + if (model !== undefined) + set.run('model', model) +} + +function makeDb(): DatabaseSync { + const db = new DatabaseSync(':memory:') + db.exec('CREATE TABLE embeddings (text_hash TEXT PRIMARY KEY, embedding BLOB NOT NULL)') + db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)') + return db +} + +function seed(db: DatabaseSync, n = 3): void { + const stmt = db.prepare('INSERT OR IGNORE INTO embeddings (text_hash, embedding) VALUES (?, ?)') + for (let i = 0; i < n; i++) + stmt.run(`hash-${i}`, Buffer.from(new Float32Array([i, i, i]).buffer)) +} + +function count(db: DatabaseSync): number { + return (db.prepare('SELECT COUNT(*) c FROM embeddings').get() as { c: number }).c +} + +describe('embedding cache identity', () => { + it('keeps cached vectors when model and dimensions are unchanged', () => { + const db = makeDb() + applyIdentity(db, 1024, 'model-a') + seed(db) + applyIdentity(db, 1024, 'model-a') + expect(count(db)).toBe(3) + db.close() + }) + + // The regression: equal width, different model. + it('clears cached vectors when the model changes at identical dimensions', () => { + const db = makeDb() + applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu') + seed(db) + expect(count(db)).toBe(3) + + applyIdentity(db, 1024, 'ollama:qwen3-embedding:0.6b') + expect(count(db)).toBe(0) + db.close() + }) + + it('clears cached vectors when dimensions change', () => { + const db = makeDb() + applyIdentity(db, 384, 'model-a') + seed(db) + applyIdentity(db, 1024, 'model-a') + expect(count(db)).toBe(0) + db.close() + }) + + // Same model on a different backend: numeric output can differ, so vectors + // are only interchangeable within a device. + it('clears cached vectors when only the device changes', () => { + const db = makeDb() + applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@cpu') + seed(db) + applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu') + expect(count(db)).toBe(0) + db.close() + }) + + // A cache written before the model key existed has unknown provenance. + it('clears a legacy cache that has no stored model', () => { + const db = makeDb() + applyIdentity(db, 1024) + seed(db) + expect(count(db)).toBe(3) + + applyIdentity(db, 1024, 'model-a') + expect(count(db)).toBe(0) + db.close() + }) +}) From d0c599234b258918b19567524ba506540a46bec2 Mon Sep 17 00:00:00 2001 From: "Robert Kent Jr." Date: Tue, 11 Aug 2026 16:43:56 -0400 Subject: [PATCH 2/6] feat(search): support Ollama embedding models Search was limited to the transformers.js models bundled through retriv. Ollama already hosts stronger local embedders, and skilld already talks to Ollama for completions, so the capability was one HTTP call away. Models are addressed as `ollama:`, matching the enhancement-model syntax. `skilld config` lists locally-pulled models advertising the `embedding` capability alongside the built-in ones. Discovery is additive: an unreachable daemon contributes nothing rather than erroring. Talks to `/api/embed` over plain fetch rather than retriv's Ollama provider, which would pull in `ai` and `ollama-ai-provider-v2`. That keeps the dependency footprint unchanged and matches src/agent/clis/ollama.ts, which already uses fetch against /api/chat, /api/tags and /api/show. `ollamaHost()` moves to core/ so the search worker can resolve OLLAMA_HOST without importing the agent registry. Dimensions and context length come from /api/show when the model reports them, falling back to a probe embedding. Capability confirmation is required rather than fail-open: unlike completions, a chat model errors on /api/embed, so an unconfirmed model would break indexing later. Vectors are L2-normalised on the way out. The index scores by L2 distance and assumes unit vectors; Ollama normalises server-side today, but that behaviour is undocumented and silently depending on it would make ranking correctness hostage to an implementation detail. Normalising is idempotent for unit vectors. Ollama manages its own execution device, so the embedding device setting does not apply; the picker says so rather than ignoring it silently. --- README.md | 17 ++- src/agent/clis/ollama.ts | 10 +- src/commands/config.ts | 24 +++- src/core/ollama-host.ts | 14 ++ src/retriv/index.ts | 17 ++- src/retriv/ollama-embeddings.ts | 208 ++++++++++++++++++++++++++++ test/unit/ollama-embeddings.test.ts | 188 +++++++++++++++++++++++++ 7 files changed, 457 insertions(+), 21 deletions(-) create mode 100644 src/core/ollama-host.ts create mode 100644 src/retriv/ollama-embeddings.ts create mode 100644 test/unit/ollama-embeddings.test.ts diff --git a/README.md b/README.md index b69911c5..0b2e1ae2 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ The large default context can exceed memory for big models on constrained hardwa | `bge-base-en-v1.5` | 768 | Balanced accuracy and speed. | | `bge-m3` | 1024 | Multilingual, 8192-token context. | -Larger models retrieve more accurately but cost more time and memory when indexing. Set `SKILLD_EMBED_MODEL` to override the saved setting for a single run: +Larger models retrieve more accurately but cost more time and memory when indexing. Locally-pulled [Ollama](#ollama-embedding-models) models can be used too. Set `SKILLD_EMBED_MODEL` to override the saved setting for a single run: ```bash SKILLD_EMBED_MODEL=bge-m3 skilld add npm:vue @@ -261,6 +261,21 @@ Search indexes store fixed-width vectors, so changing to a model with different skilld update --force ``` +### Ollama Embedding Models + +If [Ollama](https://ollama.com) is running, locally-pulled embedding models appear in the **Embedding model** picker alongside the built-in ones. They are addressed as `ollama:`, matching the `-m ollama:` syntax used for enhancement models: + +```bash +ollama pull qwen3-embedding +SKILLD_EMBED_MODEL=ollama:qwen3-embedding skilld add npm:vue +``` + +Only models that advertise the `embedding` capability are listed, so chat models cannot be selected by mistake. Dimensions and context length are read from Ollama, and vectors are normalised before indexing. + +This talks to Ollama's HTTP API directly — no additional dependency, and no API key. Set `OLLAMA_HOST` to point at a non-default daemon. If Ollama is not running, the picker simply shows the built-in models. + +Ollama manages its own execution device, so **Embedding device** does not apply to `ollama:` models. + ### Embedding Device The embedding model runs on the CPU by default. **Embedding device** in `skilld config` moves it onto a GPU backend, which can be substantially faster: diff --git a/src/agent/clis/ollama.ts b/src/agent/clis/ollama.ts index 72c40f4c..e2b3c813 100644 --- a/src/agent/clis/ollama.ts +++ b/src/agent/clis/ollama.ts @@ -17,6 +17,7 @@ import type { SectionExecutor } from './runner.ts' import type { OptimizeModel } from './types.ts' +import { ollamaHost } from '../../core/ollama-host.ts' const OLLAMA_PREFIX = 'ollama:' @@ -29,15 +30,6 @@ export function parseOllamaModelId(model: string): string | null { return isOllamaModel(model) ? model.slice(OLLAMA_PREFIX.length) : null } -const HAS_SCHEME_RE = /^https?:\/\// -const TRAILING_SLASH_RE = /\/$/ - -function ollamaHost(): string { - const raw = process.env.OLLAMA_HOST || 'http://localhost:11434' - const withScheme = HAS_SCHEME_RE.test(raw) ? raw : `http://${raw}` - return withScheme.replace(TRAILING_SLASH_RE, '') -} - interface OllamaChatChunk { message?: { content?: string } done?: boolean diff --git a/src/commands/config.ts b/src/commands/config.ts index bb7b8e43..b91302f6 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -12,6 +12,7 @@ import { NO_MODELS_MESSAGE, OAUTH_NOTE, pickModel } from '../cli/model-picker.ts import { defaultFeatures, readConfig, updateConfig } from '../core/config.ts' import { getProjectState } from '../core/skills.ts' import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, getEmbedModelInfo, resolveEmbedModel } from '../retriv/models.ts' +import { getAvailableOllamaEmbedModels, isOllamaEmbedModel } from '../retriv/ollama-embeddings.ts' export async function configCommand(): Promise { const initConfig = readConfig() @@ -259,13 +260,22 @@ async function configureEmbedModel(): Promise { p.log.warn(`SKILLD_EMBED_MODEL is set to ${envOverride} and overrides this setting for the current shell.`) } + const builtIn = EMBED_MODELS.map(m => ({ + label: m.label, + value: m.id, + hint: `${m.dimensions}d · ${m.hint}`, + })) + // Locally-pulled Ollama models are additive: an unreachable daemon simply + // contributes nothing rather than blocking the picker. + const ollama = (await getAvailableOllamaEmbedModels()).map(m => ({ + label: m.name, + value: m.id, + hint: m.hint, + })) + const choice = guard(await p.select({ message: 'Embedding model — indexes and queries docs for skilld search', - options: EMBED_MODELS.map(m => ({ - label: m.label, - value: m.id, - hint: `${m.dimensions}d · ${m.hint}`, - })), + options: [...builtIn, ...ollama], initialValue: current, })) @@ -294,6 +304,10 @@ async function configureEmbedModel(): Promise { async function configureEmbedDevice(): Promise { const config = readConfig() const current = config.embedDevice || DEFAULT_EMBED_DEVICE + + if (isOllamaEmbedModel(resolveEmbedModel(config.embedModel))) { + p.log.warn('The active embedding model runs inside Ollama, which manages its own device. This setting will have no effect until you switch to a built-in model.') + } const envOverride = process.env.SKILLD_EMBED_DEVICE?.trim() if (envOverride) diff --git a/src/core/ollama-host.ts b/src/core/ollama-host.ts new file mode 100644 index 00000000..af0b8f19 --- /dev/null +++ b/src/core/ollama-host.ts @@ -0,0 +1,14 @@ +const HAS_SCHEME_RE = /^https?:\/\// +const TRAILING_SLASH_RE = /\/$/ + +/** + * Base URL for the local Ollama daemon, normalised so callers can append paths. + * + * Lives in `core/` rather than `agent/clis/ollama.ts` so the search worker can + * reach it without pulling in the agent registry. + */ +export function ollamaHost(): string { + const raw = process.env.OLLAMA_HOST || 'http://localhost:11434' + const withScheme = HAS_SCHEME_RE.test(raw) ? raw : `http://${raw}` + return withScheme.replace(TRAILING_SLASH_RE, '') +} diff --git a/src/retriv/index.ts b/src/retriv/index.ts index c33941e4..75cd9ff7 100644 --- a/src/retriv/index.ts +++ b/src/retriv/index.ts @@ -2,6 +2,7 @@ import type { ChunkEntity, Document, IndexConfig, IndexPhase, IndexProgress, Sea import { readConfig } from '../core/config.ts' import { stripFrontmatter } from '../core/markdown.ts' import { resolveEmbedDevice, resolveEmbedModel } from './models.ts' +import { isOllamaEmbedModel, ollamaEmbeddings } from './ollama-embeddings.ts' export type { ChunkEntity, Document, IndexConfig, IndexPhase, IndexProgress, SearchFilter, SearchOptions, SearchResult, SearchSnippet } @@ -77,13 +78,17 @@ export async function getDb(config: Pick) { const device = resolveEmbedDevice(userConfig.embedDevice) // Cache identity pairs model with device: cached vectors are only valid for // the embedder that produced them, and backends can differ numerically. + // Ollama runs the model in its own process, so `device` does not apply there. + const isOllama = isOllamaEmbedModel(embedModel) const embeddings = await cachedEmbeddings( - transformersJs({ - model: embedModel, - // Omitted when `auto` so transformers.js keeps its own device resolution. - ...(device ? { device } : {}), - }), - `${embedModel}@${device ?? 'auto'}`, + isOllama + ? ollamaEmbeddings(embedModel) + : transformersJs({ + model: embedModel, + // Omitted when `auto` so transformers.js keeps its own device resolution. + ...(device ? { device } : {}), + }), + isOllama ? embedModel : `${embedModel}@${device ?? 'auto'}`, ) return createRetriv({ driver: sqliteMod.default({ diff --git a/src/retriv/ollama-embeddings.ts b/src/retriv/ollama-embeddings.ts new file mode 100644 index 00000000..18292aa3 --- /dev/null +++ b/src/retriv/ollama-embeddings.ts @@ -0,0 +1,208 @@ +/** + * Ollama-backed embeddings for the search index. + * + * Talks to `/api/embed` directly rather than going through retriv's own Ollama + * provider, which requires the `ai` SDK and `ollama-ai-provider-v2`. skilld + * already speaks to Ollama over plain `fetch` for completions, so this keeps + * the dependency footprint unchanged. + */ +import type { Embedding } from 'retriv' +import { ollamaHost } from '../core/ollama-host.ts' + +/** Models are addressed as `ollama:`, matching the enhancement-model syntax. */ +export const OLLAMA_PREFIX = 'ollama:' + +/** Documents sent per `/api/embed` call. Keeps payloads and timeouts bounded. */ +const BATCH_SIZE = 64 + +/** Embedding runs are slow on large models; discovery stays snappy separately. */ +const EMBED_TIMEOUT_MS = 120_000 +const DISCOVERY_TIMEOUT_MS = 1500 + +export function isOllamaEmbedModel(id: string): boolean { + return id.startsWith(OLLAMA_PREFIX) +} + +export function stripOllamaPrefix(id: string): string { + return id.startsWith(OLLAMA_PREFIX) ? id.slice(OLLAMA_PREFIX.length) : id +} + +interface OllamaShowResponse { + capabilities?: string[] + model_info?: Record +} + +interface OllamaTagsResponse { + models?: Array<{ + name: string + size?: number + details?: { parameter_size?: string, quantization_level?: string } + }> +} + +/** + * Pull a value out of `model_info`, whose keys are architecture-prefixed + * (`qwen3.embedding_length`, `gemma3.context_length`, …). + */ +function readModelInfo(info: Record | undefined, suffix: string): number | undefined { + if (!info) + return undefined + for (const [key, value] of Object.entries(info)) { + if (key.endsWith(suffix) && typeof value === 'number') + return value + } + return undefined +} + +async function showModel(name: string, timeout: number): Promise { + const res = await fetch(`${ollamaHost()}/api/show`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: name }), + signal: AbortSignal.timeout(timeout), + }).catch(() => null) + if (!res?.ok) + return null + return await res.json().catch(() => null) as OllamaShowResponse | null +} + +export interface OllamaEmbedModelInfo { + /** Prefixed id, e.g. `ollama:qwen3-embedding:0.6b` */ + id: string + name: string + dimensions?: number + hint: string +} + +/** + * Locally-pulled Ollama models that advertise the `embedding` capability. + * + * Returns `[]` when the daemon is unreachable — discovery must never block or + * throw, it just contributes nothing to the picker. + */ +export async function getAvailableOllamaEmbedModels(): Promise { + const res = await fetch(`${ollamaHost()}/api/tags`, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) }) + .catch(() => null) + if (!res?.ok) + return [] + + const data = await res.json().catch(() => null) as OllamaTagsResponse | null + if (!data?.models?.length) + return [] + + const checked = await Promise.all(data.models.map(async (m): Promise => { + const info = await showModel(m.name, DISCOVERY_TIMEOUT_MS) + // Unlike completions we cannot fail open: a chat model returns an error + // from /api/embed, so an unconfirmed model would break indexing later. + if (!info?.capabilities?.includes('embedding')) + return null + + const dimensions = readModelInfo(info.model_info, '.embedding_length') + const params = m.details?.parameter_size + const detail = [dimensions ? `${dimensions}d` : null, params].filter(Boolean).join(' · ') + return { + id: `${OLLAMA_PREFIX}${m.name}`, + name: m.name, + dimensions, + hint: detail ? `local · ${detail}` : 'local', + } + })) + + return checked.filter((m): m is OllamaEmbedModelInfo => m !== null) +} + +function l2Normalize(vector: number[]): Float32Array { + let sum = 0 + for (const value of vector) + sum += value * value + const norm = Math.sqrt(sum) + const out = new Float32Array(vector.length) + if (norm === 0) + return out + for (let i = 0; i < vector.length; i++) + out[i] = vector[i]! / norm + return out +} + +async function embedBatch(model: string, input: string[]): Promise { + const res = await fetch(`${ollamaHost()}/api/embed`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model, input }), + signal: AbortSignal.timeout(EMBED_TIMEOUT_MS), + }).catch((err) => { + throw new Error(`Could not reach Ollama at ${ollamaHost()}: ${err instanceof Error ? err.message : String(err)}`) + }) + + const data = await res.json().catch(() => null) as { embeddings?: number[][], error?: string } | null + if (!res.ok || data?.error) { + const message = data?.error || `HTTP ${res.status}` + throw new Error(`Ollama embedding failed for ${model}: ${message}`) + } + if (!data?.embeddings?.length) + throw new Error(`Ollama returned no embeddings for ${model}`) + + return data.embeddings +} + +/** + * Build a retriv `EmbeddingConfig` backed by Ollama. + * + * `id` may be prefixed or bare. Dimensions come from `/api/show` when the model + * reports them, otherwise from a probe embedding. + */ +export function ollamaEmbeddings(id: string): { + resolve: () => Promise<{ embedder: (texts: string[]) => Promise, dimensions: number, maxTokens?: number }> +} { + const model = stripOllamaPrefix(id) + let cached: { embedder: (texts: string[]) => Promise, dimensions: number, maxTokens?: number } | null = null + + return { + async resolve() { + if (cached) + return cached + + const info = await showModel(model, DISCOVERY_TIMEOUT_MS) + if (!info) { + throw new Error( + `Ollama is not reachable at ${ollamaHost()}. Start it with \`ollama serve\`, ` + + `or pick a built-in model with \`skilld config\`.`, + ) + } + if (info.capabilities && !info.capabilities.includes('embedding')) { + throw new Error( + `Ollama model "${model}" does not support embeddings. ` + + `Pull an embedding model, for example \`ollama pull qwen3-embedding\`.`, + ) + } + + let dimensions = readModelInfo(info.model_info, '.embedding_length') + if (!dimensions) { + const [probe] = await embedBatch(model, ['dimension probe']) + dimensions = probe?.length + } + if (!dimensions) + throw new Error(`Could not determine embedding dimensions for Ollama model "${model}"`) + + const maxTokens = readModelInfo(info.model_info, '.context_length') + + const embedder = async (texts: string[]): Promise => { + if (texts.length === 0) + return [] + const out: Embedding[] = [] + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = await embedBatch(model, texts.slice(i, i + BATCH_SIZE)) + // Ollama normalises server-side today; doing it here is idempotent + // and keeps ranking correct if that ever changes, since the index + // scores by L2 distance and assumes unit vectors. + for (const vector of batch) + out.push(l2Normalize(vector)) + } + return out + } + + cached = { embedder, dimensions, maxTokens } + return cached + }, + } +} diff --git a/test/unit/ollama-embeddings.test.ts b/test/unit/ollama-embeddings.test.ts new file mode 100644 index 00000000..67ebba52 --- /dev/null +++ b/test/unit/ollama-embeddings.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getAvailableOllamaEmbedModels, + isOllamaEmbedModel, + ollamaEmbeddings, + stripOllamaPrefix, +} from '../../src/retriv/ollama-embeddings.ts' + +const SHOW_EMBEDDING = { + capabilities: ['embedding'], + model_info: { 'qwen3.embedding_length': 1024, 'qwen3.context_length': 32768 }, +} + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: async () => body, + } as unknown as Response +} + +/** Route mocked fetch by URL path so tests read as request/response pairs. */ +function mockOllama(routes: Record Response | Promise>) { + return vi.fn(async (url: string | URL) => { + const path = new URL(String(url)).pathname + const handler = routes[path] + if (!handler) + throw new Error(`unexpected request to ${path}`) + return handler() + }) +} + +describe('ollama embed model ids', () => { + it('detects the prefix', () => { + expect(isOllamaEmbedModel('ollama:nomic-embed-text')).toBe(true) + expect(isOllamaEmbedModel('bge-small-en-v1.5')).toBe(false) + }) + + // Model names contain colons (`qwen3-embedding:0.6b`), so only the leading + // prefix may be stripped. + it('strips only the leading prefix', () => { + expect(stripOllamaPrefix('ollama:qwen3-embedding:0.6b')).toBe('qwen3-embedding:0.6b') + expect(stripOllamaPrefix('qwen3-embedding:0.6b')).toBe('qwen3-embedding:0.6b') + }) +}) + +describe('ollamaEmbeddings', () => { + const original = globalThis.fetch + + afterEach(() => { + globalThis.fetch = original + vi.restoreAllMocks() + }) + + it('reads dimensions and max tokens from /api/show without a probe embed', async () => { + const fetchMock = mockOllama({ '/api/show': () => jsonResponse(SHOW_EMBEDDING) }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const resolved = await ollamaEmbeddings('ollama:qwen3-embedding:0.6b').resolve() + + expect(resolved.dimensions).toBe(1024) + expect(resolved.maxTokens).toBe(32768) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to a probe embed when the model reports no embedding_length', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['embedding'], model_info: {} }), + '/api/embed': () => jsonResponse({ embeddings: [[0, 1, 0]] }), + }) as unknown as typeof fetch + + const resolved = await ollamaEmbeddings('ollama:mystery-model').resolve() + expect(resolved.dimensions).toBe(3) + }) + + it('normalizes returned vectors to unit length', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse(SHOW_EMBEDDING), + // Deliberately unnormalized: the index scores by L2 distance and assumes + // unit vectors, so magnitude must not leak into ranking. + '/api/embed': () => jsonResponse({ embeddings: [[3, 4], [0, 10]] }), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + const [a, b] = await embedder(['one', 'two']) + + const norm = (v: ArrayLike) => Math.sqrt(Array.from(v).reduce((s, x) => s + x * x, 0)) + expect(norm(a!)).toBeCloseTo(1, 6) + expect(norm(b!)).toBeCloseTo(1, 6) + // [3, 4] has magnitude 5, so it normalizes to [0.6, 0.8]. + expect(a![0]).toBeCloseTo(0.6, 6) + expect(a![1]).toBeCloseTo(0.8, 6) + }) + + it('returns an empty array without calling the API for no input', async () => { + const fetchMock = mockOllama({ '/api/show': () => jsonResponse(SHOW_EMBEDDING) }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + expect(await embedder([])).toEqual([]) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('explains how to start Ollama when the daemon is unreachable', async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + await expect(ollamaEmbeddings('ollama:x').resolve()).rejects.toThrow(/not reachable/i) + }) + + it('rejects a model that does not support embeddings', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['completion'], model_info: {} }), + }) as unknown as typeof fetch + + await expect(ollamaEmbeddings('ollama:llama3').resolve()).rejects.toThrow(/does not support embeddings/i) + }) + + it('surfaces the Ollama error message when embedding fails', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse(SHOW_EMBEDDING), + '/api/embed': () => jsonResponse({ error: 'model "x" not found, try pulling it first' }, false, 404), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + await expect(embedder(['hi'])).rejects.toThrow(/try pulling it first/) + }) +}) + +describe('getAvailableOllamaEmbedModels', () => { + const original = globalThis.fetch + + beforeEach(() => { + delete process.env.OLLAMA_HOST + }) + + afterEach(() => { + globalThis.fetch = original + vi.restoreAllMocks() + }) + + // Discovery feeds a config menu; it must degrade to an empty list rather + // than block or throw when Ollama is not installed. + it('returns an empty list when the daemon is unreachable', async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + + await expect(getAvailableOllamaEmbedModels()).resolves.toEqual([]) + }) + + it('keeps only models advertising the embedding capability', async () => { + globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const path = new URL(String(url)).pathname + if (path === '/api/tags') { + return jsonResponse({ + models: [ + { name: 'qwen3-embedding:0.6b', details: { parameter_size: '595M' } }, + { name: 'llama3:8b', details: { parameter_size: '8B' } }, + ], + }) + } + const body = JSON.parse(String(init?.body)) as { model: string } + return jsonResponse(body.model.startsWith('qwen3') + ? SHOW_EMBEDDING + : { capabilities: ['completion'], model_info: {} }) + }) as unknown as typeof fetch + + const models = await getAvailableOllamaEmbedModels() + + expect(models).toHaveLength(1) + expect(models[0]!.id).toBe('ollama:qwen3-embedding:0.6b') + expect(models[0]!.dimensions).toBe(1024) + expect(models[0]!.hint).toContain('1024d') + }) + + it('drops models whose capabilities cannot be confirmed', async () => { + globalThis.fetch = vi.fn(async (url: string | URL) => { + const path = new URL(String(url)).pathname + if (path === '/api/tags') + return jsonResponse({ models: [{ name: 'mystery' }] }) + return jsonResponse({}, false, 500) + }) as unknown as typeof fetch + + await expect(getAvailableOllamaEmbedModels()).resolves.toEqual([]) + }) +}) From 1bc76caf10fc27c458e44b0c3eb2fd5352da88c2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 12 Aug 2026 15:37:05 +1000 Subject: [PATCH 3/6] fix(search): keep embedding indexes provider-safe --- README.md | 27 +++--- src/cache/internal/references.ts | 3 +- src/commands/config.ts | 26 ++---- src/retriv/embedding-cache.ts | 50 ++++------ src/retriv/index-identity.ts | 68 ++++++++++++++ src/retriv/index-pipeline.ts | 7 +- src/retriv/index.ts | 24 ++++- src/retriv/models.ts | 23 +++-- src/retriv/ollama-embeddings.ts | 38 +++++--- src/retriv/worker.ts | 19 ++-- test/unit/embed-models.test.ts | 9 ++ test/unit/embedding-cache-identity.test.ts | 103 --------------------- test/unit/embedding-cache.test.ts | 33 ++++--- test/unit/index-identity.test.ts | 41 ++++++++ test/unit/ollama-embeddings.test.ts | 53 ++++++++++- test/unit/sync-pipeline.test.ts | 23 +++++ 16 files changed, 341 insertions(+), 206 deletions(-) create mode 100644 src/retriv/index-identity.ts delete mode 100644 test/unit/embedding-cache-identity.test.ts create mode 100644 test/unit/index-identity.test.ts diff --git a/README.md b/README.md index 0b2e1ae2..5d06903f 100644 --- a/README.md +++ b/README.md @@ -241,24 +241,26 @@ The large default context can exceed memory for big models on constrained hardwa ### Embedding Model -`skilld search` is powered by a local embedding model. It runs offline through transformers.js — no API key, and no network traffic after the first download. Pick one under **Embedding model** in `skilld config`: +`skilld search` is powered by a local embedding model. It runs offline through transformers.js. It needs no API key or network traffic after the first download. Pick one under **Embedding model** in `skilld config`: | Model | Dimensions | Notes | |-------|-----------:|-------| | `bge-small-en-v1.5` | 384 | Default. Fastest to index, smallest download. | | `bge-base-en-v1.5` | 768 | Balanced accuracy and speed. | +| `Xenova/bge-large-en-v1.5` | 1024 | Most accurate English retrieval, slowest to index. | | `bge-m3` | 1024 | Multilingual, 8192-token context. | -Larger models retrieve more accurately but cost more time and memory when indexing. Locally-pulled [Ollama](#ollama-embedding-models) models can be used too. Set `SKILLD_EMBED_MODEL` to override the saved setting for a single run: +Larger models retrieve more accurately but cost more time and memory when indexing. Locally-pulled [Ollama](#ollama-embedding-models) models can be used too. Export `SKILLD_EMBED_MODEL` to override the saved setting for every index and search command in the current shell: ```bash -SKILLD_EMBED_MODEL=bge-m3 skilld add npm:vue +export SKILLD_EMBED_MODEL=bge-m3 +skilld update ``` -Search indexes store fixed-width vectors, so changing to a model with different dimensions strands existing indexes. Rebuild them after switching: +Search indexes must use one embedding model and device. Rebuild them after switching either setting: ```bash -skilld update --force +skilld update ``` ### Ollama Embedding Models @@ -270,9 +272,9 @@ ollama pull qwen3-embedding SKILLD_EMBED_MODEL=ollama:qwen3-embedding skilld add npm:vue ``` -Only models that advertise the `embedding` capability are listed, so chat models cannot be selected by mistake. Dimensions and context length are read from Ollama, and vectors are normalised before indexing. +Only models that advertise the `embedding` capability are listed, so chat models cannot be selected by mistake. Dimensions and context length are read from Ollama. skilld validates each vector before indexing. -This talks to Ollama's HTTP API directly — no additional dependency, and no API key. Set `OLLAMA_HOST` to point at a non-default daemon. If Ollama is not running, the picker simply shows the built-in models. +This talks to Ollama's HTTP API directly. It needs no additional dependency or API key. Set `OLLAMA_HOST` to point at a non-default daemon. If Ollama is not running, the picker simply shows the built-in models. Ollama manages its own execution device, so **Embedding device** does not apply to `ollama:` models. @@ -282,7 +284,7 @@ The embedding model runs on the CPU by default. **Embedding device** in `skilld | Device | Notes | |--------|-------| -| `auto` | Default. Lets transformers.js choose — CPU under Node. | +| `auto` | Default. Lets transformers.js choose; CPU under Node. | | `cpu` | Always available, predictable. | | `webgpu` | Fastest on Apple Silicon in testing. | | `coreml` | Apple Neural Engine. Measured slower than CPU for these models. | @@ -295,15 +297,16 @@ Measured on an Apple M5 Max, 120 documents, best of 3 after warm-up (docs/sec): | `bge-base-en-v1.5` | 198 | 68 | **580** | | `Xenova/bge-large-en-v1.5` | 71 | 9 | **201** | -WebGPU was 2.6-2.9x faster than CPU at every size, which means `bge-large` on WebGPU indexes faster than `bge-base` does on CPU — better retrieval for less wall-clock. CoreML was consistently slower. +WebGPU was 2.6-2.9x faster than CPU at every size. `bge-large` on WebGPU indexes faster than `bge-base` does on CPU, with better retrieval and less wall-clock time. CoreML was consistently slower. -The ranking is hardware-specific, so benchmark before trusting a device on other machines. Override for a single run with `SKILLD_EMBED_DEVICE`: +The ranking is hardware-specific, so benchmark before trusting a device on other machines. Export `SKILLD_EMBED_DEVICE` to override the saved setting in the current shell: ```bash -SKILLD_EMBED_DEVICE=cpu skilld update --force +export SKILLD_EMBED_DEVICE=cpu +skilld update ``` -If a backend is unavailable, indexing fails to start — switch back to `auto`. +If a backend is unavailable, indexing fails to start. Switch back to `auto`. ### Eject diff --git a/src/cache/internal/references.ts b/src/cache/internal/references.ts index f5326c1e..7903c0bc 100644 --- a/src/cache/internal/references.ts +++ b/src/cache/internal/references.ts @@ -20,6 +20,7 @@ import { dirname, join } from 'pathe' import { defaultFeatures, readConfig } from '../../core/config.ts' import { getPackageDbPath, getRepoCacheDir, skillInternalDir } from '../../core/paths.ts' import { hasShippedDocs } from '../../core/prepare.ts' +import { hasIndexEmbeddingIdentity, resolveEmbeddingIdentity } from '../../retriv/index-identity.ts' import { classifyCachedDoc } from './classify.ts' import { clearCache, @@ -202,7 +203,7 @@ export function loadCachedReferences(opts: LoadCachedReferencesOptions): CachedR // Load cached docs for indexing if db doesn't exist yet const dbPath = getPackageDbPath(packageName, version) - if (!existsSync(dbPath)) { + if (!existsSync(dbPath) || !hasIndexEmbeddingIdentity(dbPath, resolveEmbeddingIdentity())) { onProgress('Reading cached docs for indexing') const cached = readCachedDocs(packageName, version) for (const doc of cached) { diff --git a/src/commands/config.ts b/src/commands/config.ts index b91302f6..bd2f0c11 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -11,7 +11,7 @@ import { guard, menuLoop } from '../cli/menu.ts' import { NO_MODELS_MESSAGE, OAUTH_NOTE, pickModel } from '../cli/model-picker.ts' import { defaultFeatures, readConfig, updateConfig } from '../core/config.ts' import { getProjectState } from '../core/skills.ts' -import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, getEmbedModelInfo, resolveEmbedModel } from '../retriv/models.ts' +import { DEFAULT_EMBED_DEVICE, DEFAULT_EMBED_MODEL, EMBED_DEVICES, EMBED_MODELS, resolveEmbedModel } from '../retriv/models.ts' import { getAvailableOllamaEmbedModels, isOllamaEmbedModel } from '../retriv/ollama-embeddings.ts' export async function configCommand(): Promise { @@ -274,7 +274,7 @@ async function configureEmbedModel(): Promise { })) const choice = guard(await p.select({ - message: 'Embedding model — indexes and queries docs for skilld search', + message: 'Embedding model: indexes and queries docs for skilld search', options: [...builtIn, ...ollama], initialValue: current, })) @@ -284,19 +284,9 @@ async function configureEmbedModel(): Promise { return } - const previous = getEmbedModelInfo(current) - const next = getEmbedModelInfo(choice as string) updateConfig({ embedModel: choice === DEFAULT_EMBED_MODEL ? undefined : choice as string }) p.log.success(`Embedding model set to ${choice}`) - - // sqlite-vec columns are fixed-width, so a dimension change strands existing - // indexes: they stay queryable at the old width but new docs cannot join them. - if (previous && next && previous.dimensions !== next.dimensions) { - p.log.warn( - `Vector width changed ${previous.dimensions}d → ${next.dimensions}d. ` - + 'Existing search indexes must be rebuilt: skilld update --force', - ) - } + p.log.warn('Run `skilld update` to rebuild existing search indexes with this model.') } // ── Embedding device selection ─────────────────────────────────────── @@ -305,7 +295,8 @@ async function configureEmbedDevice(): Promise { const config = readConfig() const current = config.embedDevice || DEFAULT_EMBED_DEVICE - if (isOllamaEmbedModel(resolveEmbedModel(config.embedModel))) { + const isOllama = isOllamaEmbedModel(resolveEmbedModel(config.embedModel)) + if (isOllama) { p.log.warn('The active embedding model runs inside Ollama, which manages its own device. This setting will have no effect until you switch to a built-in model.') } const envOverride = process.env.SKILLD_EMBED_DEVICE?.trim() @@ -321,7 +312,7 @@ async function configureEmbedDevice(): Promise { ) const choice = guard(await p.select({ - message: 'Embedding device — where the model runs', + message: 'Embedding device: where the model runs', options: EMBED_DEVICES.map(d => ({ label: d.label, value: d.id, hint: d.hint })), initialValue: current, })) @@ -329,8 +320,11 @@ async function configureEmbedDevice(): Promise { updateConfig({ embedDevice: choice === DEFAULT_EMBED_DEVICE ? undefined : choice as string }) p.log.success(`Embedding device set to ${choice}`) + if (!isOllama) + p.log.warn('Run `skilld update` to rebuild existing search indexes on this device.') + if (choice !== DEFAULT_EMBED_DEVICE && choice !== 'cpu') { - p.log.info('If indexing fails to start, the backend is unavailable on this machine — switch back to Auto.') + p.log.info('If indexing fails to start, the backend is unavailable on this machine. Switch back to Auto.') } } diff --git a/src/retriv/embedding-cache.ts b/src/retriv/embedding-cache.ts index 126d3dde..1631a2cf 100644 --- a/src/retriv/embedding-cache.ts +++ b/src/retriv/embedding-cache.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from 'node:sqlite' import type { Embedding } from 'retriv' +import { createHash } from 'node:crypto' import { rmSync } from 'node:fs' import { join } from 'pathe' import { CACHE_DIR } from '../cache/index.ts' @@ -20,7 +21,6 @@ async function openDb(): Promise { db.exec('PRAGMA journal_mode=WAL') db.exec('PRAGMA busy_timeout=5000') db.exec(`CREATE TABLE IF NOT EXISTS embeddings (text_hash TEXT PRIMARY KEY, embedding BLOB NOT NULL)`) - db.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) _db = db return db } @@ -32,20 +32,21 @@ function closeDb(): void { } } -function createSqliteStorage(db: DatabaseSync) { +function createSqliteStorage(db: DatabaseSync, getNamespace: () => string) { const getStmt = db.prepare('SELECT embedding FROM embeddings WHERE text_hash = ?') const setStmt = db.prepare('INSERT OR IGNORE INTO embeddings (text_hash, embedding) VALUES (?, ?)') + const key = (hash: string) => `${getNamespace()}:${hash}` return { get: (hash: string): Embedding | null => { - const row = getStmt.get(hash) as { embedding: Buffer } | undefined + const row = getStmt.get(key(hash)) as { embedding: Buffer } | undefined if (!row) return null return new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4) }, set: (hash: string, embedding: Embedding): void => { const arr = embedding instanceof Float32Array ? embedding : new Float32Array(embedding) - setStmt.run(hash, Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength)) + setStmt.run(key(hash), Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength)) }, } } @@ -53,43 +54,32 @@ function createSqliteStorage(db: DatabaseSync) { /** * Wrap an embedding provider with the on-disk vector cache. * - * `model` identifies which embedder produced the cached vectors. Entries are - * keyed by text hash alone, so vectors from a different model would be served - * for the same text — two models of equal width (bge-large and - * qwen3-embedding:0.6b are both 1024d) would silently mix embedding spaces and - * destroy ranking. Dimensions alone cannot catch that; the model id can. + * `identity` identifies the model and execution backend. It namespaces every + * text hash, so concurrent providers cannot read or overwrite each other's + * vectors. Dimensions join the namespace after the provider resolves. */ -export async function cachedEmbeddings(config: EmbeddingConfig, model?: string): Promise { +export async function cachedEmbeddings(config: EmbeddingConfig, identity: string): Promise { const { cachedEmbeddings: retrivCached } = await import('retriv/embeddings/cached') const db = await openDb() - const storage = createSqliteStorage(db) + let namespace: string | undefined + const storage = createSqliteStorage(db, () => { + if (!namespace) + throw new Error('Embedding cache used before its provider resolved') + return namespace + }) const originalResolve = config.resolve - const validatedConfig: EmbeddingConfig = { + const namespacedConfig: EmbeddingConfig = { async resolve() { const resolved = await originalResolve() - const getMetaStmt = db.prepare('SELECT value FROM meta WHERE key = ?') - const setMetaStmt = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)') - - const storedDims = getMetaStmt.get('dimensions') as { value: string } | undefined - const storedModel = getMetaStmt.get('model') as { value: string } | undefined - const dimsChanged = storedDims && Number(storedDims.value) !== resolved.dimensions - // A cache written before this key existed has unknown provenance, so - // treat a missing stored model as a mismatch once a model is supplied. - const modelChanged = model !== undefined && storedModel?.value !== model - - if (dimsChanged || modelChanged) - db.exec('DELETE FROM embeddings') - - setMetaStmt.run('dimensions', String(resolved.dimensions)) - if (model !== undefined) - setMetaStmt.run('model', model) - + namespace = createHash('sha256') + .update(`${identity}\0${resolved.dimensions}`) + .digest('hex') return resolved }, } - return retrivCached(validatedConfig, { storage }) + return retrivCached(namespacedConfig, { storage }) } export function clearEmbeddingCache(): void { diff --git a/src/retriv/index-identity.ts b/src/retriv/index-identity.ts new file mode 100644 index 00000000..ea4b6726 --- /dev/null +++ b/src/retriv/index-identity.ts @@ -0,0 +1,68 @@ +import type { SkilldConfig } from '../core/config.ts' +import { createHash } from 'node:crypto' +import { existsSync, rmSync } from 'node:fs' +import { readConfig } from '../core/config.ts' +import { ollamaHost } from '../core/ollama-host.ts' +import { resolveEmbedDevice, resolveEmbedModel } from './models.ts' +import { isOllamaEmbedModel } from './ollama-embeddings.ts' + +const META_TABLE = 'skilld_meta' +const IDENTITY_KEY = 'embedding_identity' +const IDENTITY_VERSION = 'v1' + +export function resolveEmbeddingIdentity( + config: Pick = readConfig(), +): string { + const model = resolveEmbedModel(config.embedModel) + if (!isOllamaEmbedModel(model)) + return `${IDENTITY_VERSION}:${model}@${resolveEmbedDevice(config.embedDevice) ?? 'auto'}` + + const host = createHash('sha256').update(ollamaHost()).digest('hex').slice(0, 16) + return `${IDENTITY_VERSION}:${model}@host:${host}` +} + +export function readIndexEmbeddingIdentity(dbPath: string): string | undefined { + if (!existsSync(dbPath)) + return undefined + const nodeSqlite = globalThis.process?.getBuiltinModule?.('node:sqlite') as typeof import('node:sqlite') | undefined + if (!nodeSqlite) + return undefined + + const db = new nodeSqlite.DatabaseSync(dbPath, { open: true, readOnly: true }) + try { + const table = db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(META_TABLE) + if (!table) + return undefined + const row = db.prepare(`SELECT value FROM ${META_TABLE} WHERE key = ?`).get(IDENTITY_KEY) as { value: string } | undefined + return row?.value + } + finally { + db.close() + } +} + +export function hasIndexEmbeddingIdentity(dbPath: string, identity: string): boolean { + return readIndexEmbeddingIdentity(dbPath) === identity +} + +export function removeStaleIndex(dbPath: string, identity: string): boolean { + if (!existsSync(dbPath) || hasIndexEmbeddingIdentity(dbPath, identity)) + return false + for (const path of [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]) + rmSync(path, { force: true }) + return true +} + +export function writeIndexEmbeddingIdentity(dbPath: string, identity: string): void { + const nodeSqlite = globalThis.process?.getBuiltinModule?.('node:sqlite') as typeof import('node:sqlite') | undefined + if (!nodeSqlite) + throw new Error('SQLite is unavailable, so the search index identity cannot be saved') + const db = new nodeSqlite.DatabaseSync(dbPath) + try { + db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`) + db.prepare(`INSERT OR REPLACE INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(IDENTITY_KEY, identity) + } + finally { + db.close() + } +} diff --git a/src/retriv/index-pipeline.ts b/src/retriv/index-pipeline.ts index 6d19cdcc..dc84778f 100644 --- a/src/retriv/index-pipeline.ts +++ b/src/retriv/index-pipeline.ts @@ -14,6 +14,7 @@ import { getPackageDbPath } from '../cache/index.ts' import { defaultFeatures, readConfig } from '../core/config.ts' import { resolvePkgDir } from '../core/prepare.ts' import { resolveEntryFiles } from '../sources/index.ts' +import { hasIndexEmbeddingIdentity, resolveEmbeddingIdentity } from './index-identity.ts' import { createIndex, listIndexIds, SearchDepsUnavailableError } from './index.ts' /** Max docs sent to the embedding pipeline to prevent oversized indexes */ @@ -62,7 +63,11 @@ export async function indexResources(opts: IndexResourcesOptions): Promise return const dbPath = getPackageDbPath(packageName, version) - const dbExists = existsSync(dbPath) + const storedDbExists = existsSync(dbPath) + const identity = resolveEmbeddingIdentity() + const dbExists = storedDbExists && hasIndexEmbeddingIdentity(dbPath, identity) + if (storedDbExists && !dbExists) + onProgress('Embedding settings changed, rebuilding search index') const allDocs = [...opts.docsToIndex] diff --git a/src/retriv/index.ts b/src/retriv/index.ts index 75cd9ff7..6510b692 100644 --- a/src/retriv/index.ts +++ b/src/retriv/index.ts @@ -1,6 +1,8 @@ import type { ChunkEntity, Document, IndexConfig, IndexPhase, IndexProgress, SearchFilter, SearchOptions, SearchResult, SearchSnippet } from './types.ts' +import { existsSync } from 'node:fs' import { readConfig } from '../core/config.ts' import { stripFrontmatter } from '../core/markdown.ts' +import { hasIndexEmbeddingIdentity, removeStaleIndex, resolveEmbeddingIdentity } from './index-identity.ts' import { resolveEmbedDevice, resolveEmbedModel } from './models.ts' import { isOllamaEmbedModel, ollamaEmbeddings } from './ollama-embeddings.ts' @@ -46,7 +48,7 @@ function checkFts5(): boolean { } // Dynamic imports: retriv/chunkers/auto eagerly loads typescript which may not be installed (e.g. npx) -export async function getDb(config: Pick) { +async function openDb(config: Pick, identity: string) { if (!checkFts5()) throw new SearchDepsUnavailableError(new Error('FTS5 module not available'), 'SQLite FTS5 module not available. Search indexing skipped. On Windows, run from WSL where FTS5 is included.') @@ -88,7 +90,7 @@ export async function getDb(config: Pick) { // Omitted when `auto` so transformers.js keeps its own device resolution. ...(device ? { device } : {}), }), - isOllama ? embedModel : `${embedModel}@${device ?? 'auto'}`, + identity, ) return createRetriv({ driver: sqliteMod.default({ @@ -100,6 +102,24 @@ export async function getDb(config: Pick) { }) } +export async function getDb(config: Pick) { + const identity = resolveEmbeddingIdentity() + if (existsSync(config.dbPath) && !hasIndexEmbeddingIdentity(config.dbPath, identity)) { + throw new Error( + 'Search index uses different embedding settings. Run `skilld update` to rebuild it.', + ) + } + return openDb(config, identity) +} + +export async function getIndexDb( + config: Pick, + identity: string = resolveEmbeddingIdentity(), +) { + removeStaleIndex(config.dbPath, identity) + return openDb(config, identity) +} + /** * Index documents in a background worker thread. * Falls back to direct indexing if worker fails to spawn. diff --git a/src/retriv/models.ts b/src/retriv/models.ts index ee2d7eda..2c9fab62 100644 --- a/src/retriv/models.ts +++ b/src/retriv/models.ts @@ -1,19 +1,19 @@ /** * Local embedding models available to the search index. * - * Every model here runs offline through transformers.js — no API key, no + * Every model here runs offline through transformers.js. It needs no API key or * network after the initial download. Larger models retrieve more accurately * but cost more time and memory to index with. * * Dimensions are fixed per model and sqlite-vec columns are fixed-width, so * switching model invalidates existing indexes. Rebuild with - * `skilld update --force`. + * `skilld update`. */ export interface EmbedModelInfo { /** Model id passed to retriv (resolved to a Hugging Face repo internally) */ id: string label: string - /** Vector width — determines index layout */ + /** Vector width, which determines index layout */ dimensions: number hint: string } @@ -77,18 +77,20 @@ export function resolveEmbedModel(configured?: string): string { * unsupported ops and pays for graph partitioning). */ export interface EmbedDeviceInfo { - id: string + id: EmbedDeviceSetting label: string hint: string } export const DEFAULT_EMBED_DEVICE = 'auto' +export type EmbedDevice = 'cpu' | 'webgpu' | 'coreml' +export type EmbedDeviceSetting = typeof DEFAULT_EMBED_DEVICE | EmbedDevice export const EMBED_DEVICES: readonly EmbedDeviceInfo[] = [ { id: 'auto', label: 'Auto', - hint: 'let transformers.js choose — CPU under Node', + hint: 'let transformers.js choose; CPU under Node', }, { id: 'cpu', @@ -98,12 +100,12 @@ export const EMBED_DEVICES: readonly EmbedDeviceInfo[] = [ { id: 'webgpu', label: 'GPU (WebGPU)', - hint: 'fastest on Apple Silicon in testing — verify on your hardware', + hint: 'fastest on Apple Silicon in testing; verify on your hardware', }, { id: 'coreml', label: 'CoreML', - hint: 'Apple Neural Engine — measured slower than CPU for these models', + hint: 'Apple Neural Engine; measured slower than CPU for these models', }, ] @@ -115,8 +117,11 @@ export function getEmbedDeviceInfo(id: string): EmbedDeviceInfo | undefined { * Resolve the execution device. Returns `undefined` for `auto` so the option * is omitted entirely and transformers.js keeps its own default resolution. */ -export function resolveEmbedDevice(configured?: string): string | undefined { +export function resolveEmbedDevice(configured?: string): EmbedDevice | undefined { const fromEnv = process.env.SKILLD_EMBED_DEVICE?.trim() const value = fromEnv || configured || DEFAULT_EMBED_DEVICE - return value === DEFAULT_EMBED_DEVICE ? undefined : value + const device = getEmbedDeviceInfo(value) + if (!device) + throw new Error(`Unknown embedding device "${value}". Run \`skilld config\` to choose a supported device.`) + return device.id === DEFAULT_EMBED_DEVICE ? undefined : device.id } diff --git a/src/retriv/ollama-embeddings.ts b/src/retriv/ollama-embeddings.ts index 18292aa3..b0ac6f38 100644 --- a/src/retriv/ollama-embeddings.ts +++ b/src/retriv/ollama-embeddings.ts @@ -77,7 +77,7 @@ export interface OllamaEmbedModelInfo { /** * Locally-pulled Ollama models that advertise the `embedding` capability. * - * Returns `[]` when the daemon is unreachable — discovery must never block or + * Returns `[]` when the daemon is unreachable. Discovery must never block or * throw, it just contributes nothing to the picker. */ export async function getAvailableOllamaEmbedModels(): Promise { @@ -111,20 +111,29 @@ export async function getAvailableOllamaEmbedModels(): Promise m !== null) } -function l2Normalize(vector: number[]): Float32Array { +function l2Normalize(vector: unknown, expectedDimensions?: number): Float32Array { + if (!Array.isArray(vector) || !vector.every(value => typeof value === 'number' && Number.isFinite(value))) + throw new Error('Ollama returned an invalid embedding vector') + if (expectedDimensions !== undefined && vector.length !== expectedDimensions) { + throw new Error( + `Ollama returned ${vector.length} dimensions, expected ${expectedDimensions}`, + ) + } let sum = 0 for (const value of vector) sum += value * value const norm = Math.sqrt(sum) const out = new Float32Array(vector.length) if (norm === 0) - return out + throw new Error('Ollama returned a zero vector') + if (!Number.isFinite(norm)) + throw new Error('Ollama returned an invalid vector magnitude') for (let i = 0; i < vector.length; i++) out[i] = vector[i]! / norm return out } -async function embedBatch(model: string, input: string[]): Promise { +async function embedBatch(model: string, input: string[], expectedDimensions?: number): Promise { const res = await fetch(`${ollamaHost()}/api/embed`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -134,15 +143,20 @@ async function embedBatch(model: string, input: string[]): Promise { throw new Error(`Could not reach Ollama at ${ollamaHost()}: ${err instanceof Error ? err.message : String(err)}`) }) - const data = await res.json().catch(() => null) as { embeddings?: number[][], error?: string } | null + const data = await res.json().catch(() => null) as { embeddings?: unknown[], error?: string } | null if (!res.ok || data?.error) { const message = data?.error || `HTTP ${res.status}` throw new Error(`Ollama embedding failed for ${model}: ${message}`) } - if (!data?.embeddings?.length) + if (!Array.isArray(data?.embeddings) || data.embeddings.length === 0) throw new Error(`Ollama returned no embeddings for ${model}`) + if (data.embeddings.length !== input.length) { + throw new Error( + `Ollama returned ${data.embeddings.length} embeddings for ${input.length} inputs`, + ) + } - return data.embeddings + return data.embeddings.map(vector => l2Normalize(vector, expectedDimensions)) } /** @@ -169,7 +183,7 @@ export function ollamaEmbeddings(id: string): { + `or pick a built-in model with \`skilld config\`.`, ) } - if (info.capabilities && !info.capabilities.includes('embedding')) { + if (!info.capabilities?.includes('embedding')) { throw new Error( `Ollama model "${model}" does not support embeddings. ` + `Pull an embedding model, for example \`ollama pull qwen3-embedding\`.`, @@ -191,12 +205,8 @@ export function ollamaEmbeddings(id: string): { return [] const out: Embedding[] = [] for (let i = 0; i < texts.length; i += BATCH_SIZE) { - const batch = await embedBatch(model, texts.slice(i, i + BATCH_SIZE)) - // Ollama normalises server-side today; doing it here is idempotent - // and keeps ranking correct if that ever changes, since the index - // scores by L2 distance and assumes unit vectors. - for (const vector of batch) - out.push(l2Normalize(vector)) + const batch = await embedBatch(model, texts.slice(i, i + BATCH_SIZE), dimensions) + out.push(...batch) } return out } diff --git a/src/retriv/worker.ts b/src/retriv/worker.ts index fe480e25..8c3cfff3 100644 --- a/src/retriv/worker.ts +++ b/src/retriv/worker.ts @@ -55,12 +55,19 @@ if (parentPort) { }, } - const { getDb } = await import('./index.ts') - const db = await getDb(config) - if (msg.removeIds?.length) - await db.remove?.(msg.removeIds) - await db.index(documents, { onProgress: config.onProgress }) - await db.close?.() + const { getIndexDb } = await import('./index.ts') + const { resolveEmbeddingIdentity, writeIndexEmbeddingIdentity } = await import('./index-identity.ts') + const identity = resolveEmbeddingIdentity() + const db = await getIndexDb(config, identity) + try { + if (msg.removeIds?.length) + await db.remove?.(msg.removeIds) + await db.index(documents, { onProgress: config.onProgress }) + } + finally { + await db.close?.() + } + writeIndexEmbeddingIdentity(dbPath, identity) parentPort!.postMessage({ type: 'done', id } satisfies WorkerDoneResponse) } diff --git a/test/unit/embed-models.test.ts b/test/unit/embed-models.test.ts index 88a64786..3b5439cf 100644 --- a/test/unit/embed-models.test.ts +++ b/test/unit/embed-models.test.ts @@ -115,6 +115,15 @@ describe('resolveEmbedDevice', () => { process.env.SKILLD_EMBED_DEVICE = 'auto' expect(resolveEmbedDevice('webgpu')).toBeUndefined() }) + + it('rejects an unknown configured device', () => { + expect(() => resolveEmbedDevice('quantum')).toThrow(/unknown embedding device/i) + }) + + it('rejects an unknown device from the environment', () => { + process.env.SKILLD_EMBED_DEVICE = 'quantum' + expect(() => resolveEmbedDevice('cpu')).toThrow(/unknown embedding device/i) + }) }) describe('embed device registry', () => { diff --git a/test/unit/embedding-cache-identity.test.ts b/test/unit/embedding-cache-identity.test.ts deleted file mode 100644 index 00b6c569..00000000 --- a/test/unit/embedding-cache-identity.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { DatabaseSync } from 'node:sqlite' -import { describe, expect, it } from 'vitest' - -/** - * Guards the cache-invalidation rule in `src/retriv/embedding-cache.ts`. - * - * Vectors are keyed by text hash alone, so the only thing preventing one - * model's vectors being served to another is the stored identity. Dimensions - * are not enough: `Xenova/bge-large-en-v1.5` and `ollama:qwen3-embedding:0.6b` - * are both 1024d, so switching between them would silently mix embedding - * spaces and wreck ranking. - * - * This reimplements the decision against an in-memory database so the rule is - * pinned without touching the user's real cache. - */ -function applyIdentity(db: DatabaseSync, dimensions: number, model?: string): void { - const get = db.prepare('SELECT value FROM meta WHERE key = ?') - const set = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)') - - const storedDims = get.get('dimensions') as { value: string } | undefined - const storedModel = get.get('model') as { value: string } | undefined - const dimsChanged = storedDims && Number(storedDims.value) !== dimensions - const modelChanged = model !== undefined && storedModel?.value !== model - - if (dimsChanged || modelChanged) - db.exec('DELETE FROM embeddings') - - set.run('dimensions', String(dimensions)) - if (model !== undefined) - set.run('model', model) -} - -function makeDb(): DatabaseSync { - const db = new DatabaseSync(':memory:') - db.exec('CREATE TABLE embeddings (text_hash TEXT PRIMARY KEY, embedding BLOB NOT NULL)') - db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)') - return db -} - -function seed(db: DatabaseSync, n = 3): void { - const stmt = db.prepare('INSERT OR IGNORE INTO embeddings (text_hash, embedding) VALUES (?, ?)') - for (let i = 0; i < n; i++) - stmt.run(`hash-${i}`, Buffer.from(new Float32Array([i, i, i]).buffer)) -} - -function count(db: DatabaseSync): number { - return (db.prepare('SELECT COUNT(*) c FROM embeddings').get() as { c: number }).c -} - -describe('embedding cache identity', () => { - it('keeps cached vectors when model and dimensions are unchanged', () => { - const db = makeDb() - applyIdentity(db, 1024, 'model-a') - seed(db) - applyIdentity(db, 1024, 'model-a') - expect(count(db)).toBe(3) - db.close() - }) - - // The regression: equal width, different model. - it('clears cached vectors when the model changes at identical dimensions', () => { - const db = makeDb() - applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu') - seed(db) - expect(count(db)).toBe(3) - - applyIdentity(db, 1024, 'ollama:qwen3-embedding:0.6b') - expect(count(db)).toBe(0) - db.close() - }) - - it('clears cached vectors when dimensions change', () => { - const db = makeDb() - applyIdentity(db, 384, 'model-a') - seed(db) - applyIdentity(db, 1024, 'model-a') - expect(count(db)).toBe(0) - db.close() - }) - - // Same model on a different backend: numeric output can differ, so vectors - // are only interchangeable within a device. - it('clears cached vectors when only the device changes', () => { - const db = makeDb() - applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@cpu') - seed(db) - applyIdentity(db, 1024, 'Xenova/bge-large-en-v1.5@webgpu') - expect(count(db)).toBe(0) - db.close() - }) - - // A cache written before the model key existed has unknown provenance. - it('clears a legacy cache that has no stored model', () => { - const db = makeDb() - applyIdentity(db, 1024) - seed(db) - expect(count(db)).toBe(3) - - applyIdentity(db, 1024, 'model-a') - expect(count(db)).toBe(0) - db.close() - }) -}) diff --git a/test/unit/embedding-cache.test.ts b/test/unit/embedding-cache.test.ts index fa613264..12a6332d 100644 --- a/test/unit/embedding-cache.test.ts +++ b/test/unit/embedding-cache.test.ts @@ -43,7 +43,7 @@ describe('embedding-cache', () => { it('computes embeddings on first call (cache miss)', async () => { const { config, calls } = fakeEmbeddingConfig() - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() const result = await embedder(['hello', 'world']) @@ -55,7 +55,7 @@ describe('embedding-cache', () => { it('serves cached embeddings on second call (cache hit)', async () => { const { config, calls } = fakeEmbeddingConfig() - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() await embedder(['hello', 'world']) @@ -68,7 +68,7 @@ describe('embedding-cache', () => { it('computes only missed texts on partial cache hit', async () => { const { config, calls } = fakeEmbeddingConfig() - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() await embedder(['hello']) @@ -87,7 +87,7 @@ describe('embedding-cache', () => { return new Float32Array([counter, counter * 10]) }) }) - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() await embedder(['a', 'b']) @@ -101,17 +101,17 @@ describe('embedding-cache', () => { expect([...result[2] as Float32Array]).toEqual([1, 10]) }) - it('wipes cache on dimension mismatch', async () => { + it('does not serve cached vectors from another dimension', async () => { // First: populate with 4-dim embeddings const { config: config4, calls: calls4 } = fakeEmbeddingConfig(4) - const wrapped4 = await cachedEmbeddings(config4) + const wrapped4 = await cachedEmbeddings(config4, 'model-a@cpu') const { embedder: embedder4 } = await wrapped4.resolve() await embedder4(['hello']) expect(calls4).toHaveLength(1) // Second: resolve with 8-dim → should wipe, recompute const { config: config8, calls: calls8 } = fakeEmbeddingConfig(8) - const wrapped8 = await cachedEmbeddings(config8) + const wrapped8 = await cachedEmbeddings(config8, 'model-a@cpu') const { embedder: embedder8 } = await wrapped8.resolve() const result = await embedder8(['hello']) @@ -120,9 +120,20 @@ describe('embedding-cache', () => { expect((result[0] as Float32Array).length).toBe(8) }) + it('does not serve cached vectors from another model', async () => { + const { config, calls } = fakeEmbeddingConfig() + const first = await cachedEmbeddings(config, 'model-a@cpu') + await (await first.resolve()).embedder(['hello']) + + const second = await cachedEmbeddings(config, 'model-b@cpu') + await (await second.resolve()).embedder(['hello']) + + expect(calls).toEqual([['hello'], ['hello']]) + }) + it('clearEmbeddingCache removes the db file', async () => { const { config } = fakeEmbeddingConfig() - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() await embedder(['hello']) @@ -137,12 +148,12 @@ describe('embedding-cache', () => { const { config, calls } = fakeEmbeddingConfig() // First resolve + embed - const wrapped1 = await cachedEmbeddings(config) + const wrapped1 = await cachedEmbeddings(config, 'model-a@cpu') const { embedder: e1 } = await wrapped1.resolve() await e1(['hello']) // Second resolve (simulates new process opening same DB) - const wrapped2 = await cachedEmbeddings(config) + const wrapped2 = await cachedEmbeddings(config, 'model-a@cpu') const { embedder: e2 } = await wrapped2.resolve() await e2(['hello']) @@ -152,7 +163,7 @@ describe('embedding-cache', () => { it('handles empty input', async () => { const { config, calls } = fakeEmbeddingConfig() - const wrapped = await cachedEmbeddings(config) + const wrapped = await cachedEmbeddings(config, 'model-a@cpu') const { embedder } = await wrapped.resolve() const result = await embedder([]) diff --git a/test/unit/index-identity.test.ts b/test/unit/index-identity.test.ts new file mode 100644 index 00000000..b22b1ad3 --- /dev/null +++ b/test/unit/index-identity.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'pathe' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasIndexEmbeddingIdentity, + readIndexEmbeddingIdentity, + removeStaleIndex, + writeIndexEmbeddingIdentity, +} from '../../src/retriv/index-identity.ts' + +const TEST_DIR = join(tmpdir(), 'skilld-test-index-identity') +const DB_PATH = join(TEST_DIR, 'search.db') + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) +}) + +function createIndexFile(): void { + mkdirSync(TEST_DIR, { recursive: true }) + new DatabaseSync(DB_PATH).close() +} + +describe('search index embedding identity', () => { + it('persists the model identity in the search database', () => { + createIndexFile() + writeIndexEmbeddingIdentity(DB_PATH, 'model-a@cpu') + + expect(readIndexEmbeddingIdentity(DB_PATH)).toBe('model-a@cpu') + expect(hasIndexEmbeddingIdentity(DB_PATH, 'model-a@cpu')).toBe(true) + }) + + it('removes an index built with different embedding settings', () => { + createIndexFile() + writeIndexEmbeddingIdentity(DB_PATH, 'model-a@cpu') + + expect(removeStaleIndex(DB_PATH, 'model-b@cpu')).toBe(true) + expect(readIndexEmbeddingIdentity(DB_PATH)).toBeUndefined() + }) +}) diff --git a/test/unit/ollama-embeddings.test.ts b/test/unit/ollama-embeddings.test.ts index 67ebba52..c288df7a 100644 --- a/test/unit/ollama-embeddings.test.ts +++ b/test/unit/ollama-embeddings.test.ts @@ -75,7 +75,10 @@ describe('ollamaEmbeddings', () => { it('normalizes returned vectors to unit length', async () => { globalThis.fetch = mockOllama({ - '/api/show': () => jsonResponse(SHOW_EMBEDDING), + '/api/show': () => jsonResponse({ + capabilities: ['embedding'], + model_info: { 'test.embedding_length': 2 }, + }), // Deliberately unnormalized: the index scores by L2 distance and assumes // unit vectors, so magnitude must not leak into ranking. '/api/embed': () => jsonResponse({ embeddings: [[3, 4], [0, 10]] }), @@ -117,6 +120,54 @@ describe('ollamaEmbeddings', () => { await expect(ollamaEmbeddings('ollama:llama3').resolve()).rejects.toThrow(/does not support embeddings/i) }) + it('rejects a model whose embedding capability is missing', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ model_info: { 'llama.embedding_length': 4096 } }), + }) as unknown as typeof fetch + + await expect(ollamaEmbeddings('ollama:llama3').resolve()).rejects.toThrow(/does not support embeddings/i) + }) + + it('rejects a response with fewer vectors than inputs', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['embedding'], model_info: { 'test.embedding_length': 2 } }), + '/api/embed': () => jsonResponse({ embeddings: [[1, 0]] }), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + await expect(embedder(['one', 'two'])).rejects.toThrow(/returned 1 embeddings for 2 inputs/i) + }) + + it('rejects a vector with the wrong dimensions', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['embedding'], model_info: { 'test.embedding_length': 3 } }), + '/api/embed': () => jsonResponse({ embeddings: [[1, 0]] }), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + await expect(embedder(['one'])).rejects.toThrow(/returned 2 dimensions, expected 3/i) + }) + + it('rejects a zero vector', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['embedding'], model_info: { 'test.embedding_length': 2 } }), + '/api/embed': () => jsonResponse({ embeddings: [[0, 0]] }), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + await expect(embedder(['one'])).rejects.toThrow(/zero vector/i) + }) + + it('rejects a vector whose magnitude overflows', async () => { + globalThis.fetch = mockOllama({ + '/api/show': () => jsonResponse({ capabilities: ['embedding'], model_info: { 'test.embedding_length': 2 } }), + '/api/embed': () => jsonResponse({ embeddings: [[Number.MAX_VALUE, Number.MAX_VALUE]] }), + }) as unknown as typeof fetch + + const { embedder } = await ollamaEmbeddings('ollama:x').resolve() + await expect(embedder(['one'])).rejects.toThrow(/invalid vector magnitude/i) + }) + it('surfaces the Ollama error message when embedding fails', async () => { globalThis.fetch = mockOllama({ '/api/show': () => jsonResponse(SHOW_EMBEDDING), diff --git a/test/unit/sync-pipeline.test.ts b/test/unit/sync-pipeline.test.ts index 7191a8f8..ba1b7ce8 100644 --- a/test/unit/sync-pipeline.test.ts +++ b/test/unit/sync-pipeline.test.ts @@ -171,6 +171,11 @@ vi.mock('../../src/core/lockfile', () => ({ writeLock: vi.fn(), })) +vi.mock('../../src/retriv/index-identity', () => ({ + hasIndexEmbeddingIdentity: vi.fn(() => true), + resolveEmbeddingIdentity: vi.fn(() => 'v1:model-a@auto'), +})) + vi.mock('../../src/retriv', async (importOriginal) => { const orig = await importOriginal() return { ...orig, createIndex: vi.fn(), listIndexIds: vi.fn().mockResolvedValue([]) } @@ -188,6 +193,7 @@ const { fetchReleaseNotes, isGhAvailable, isShallowGitDocs, resolveEntryFiles } const { registerProject } = await import('../../src/core/config') const { writeLock } = await import('../../src/core/lockfile') const { createIndex, listIndexIds } = await import('../../src/retriv') +const { hasIndexEmbeddingIdentity, resolveEmbeddingIdentity } = await import('../../src/retriv/index-identity') const { getShippedSkills, linkShippedSkill, resolvePkgDir } = await import('../../src/core/prepare') const { @@ -216,6 +222,8 @@ describe('sync-shared', () => { vi.mocked(isShallowGitDocs).mockReturnValue(false) vi.mocked(resolveEntryFiles).mockResolvedValue([]) vi.mocked(fetchReleaseNotes).mockResolvedValue([]) + vi.mocked(hasIndexEmbeddingIdentity).mockReturnValue(true) + vi.mocked(resolveEmbeddingIdentity).mockReturnValue('v1:model-a@auto') }) // ── 1. classifyCachedDoc ── @@ -405,6 +413,21 @@ describe('sync-shared', () => { expect(onProgress).toHaveBeenCalledWith('Search index up to date') }) + it('rebuilds every document when embedding settings changed', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(hasIndexEmbeddingIdentity).mockReturnValue(false) + vi.mocked(resolvePkgDir).mockReturnValue(null) + const docs = [ + { id: 'a.md', content: 'existing', metadata: { type: 'doc' } }, + { id: 'b.md', content: 'existing', metadata: { type: 'doc' } }, + ] + + await indexResources({ ...baseOpts, docsToIndex: docs }) + + expect(listIndexIds).not.toHaveBeenCalled() + expect(createIndex).toHaveBeenCalledWith(docs, expect.objectContaining({ dbPath: expect.any(String) })) + }) + // 6a2: db exists with new docs → incremental index it('incrementally indexes new docs when db exists', async () => { vi.mocked(existsSync).mockReturnValue(true) From e45ad1512e296045e1b29888a7c5d0d9d250c7ad Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 12 Aug 2026 16:04:00 +1000 Subject: [PATCH 4/6] chore(deps): update retriv to 0.15.0 --- pnpm-lock.yaml | 26 +++++++++++++------------- pnpm-workspace.yaml | 3 ++- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9e7e140..9adf092b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^0.3.23 version: 0.3.23 retriv: - specifier: ^0.14.7 - version: 0.14.7 + specifier: ^0.15.0 + version: 0.15.0 std-env: specifier: ^4.2.0 version: 4.2.0 @@ -167,7 +167,7 @@ importers: version: 2.0.3 retriv: specifier: 'catalog:' - version: 0.14.7(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2) + version: 0.15.0(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2) skilld-protocol: specifier: workspace:* version: link:packages/protocol @@ -3553,18 +3553,18 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - retriv@0.14.7: - resolution: {integrity: sha512-j06MPUvbwLBp5XZHzYdZkBZG/IUmf1NH9M+DDLIVbZls4Q4wcOxQBSpPlCczAUzDDPg27IVo95gEAeaoPJSquw==} + retriv@0.15.0: + resolution: {integrity: sha512-Ya5hmjM1g8PJ3fCXDyCS44WBqOekMiv4Yr4Dspt29wqWUoY0j6LBPumRbqIOmYw8/u8z25C8KtubcfgBWGNVdg==} peerDependencies: - '@ai-sdk/cohere': ^3.0.0 - '@ai-sdk/google': ^3.0.0 - '@ai-sdk/mistral': ^3.0.0 - '@ai-sdk/openai': ^3.0.0 - '@huggingface/transformers': ^3.0.0 + '@ai-sdk/cohere': ^4.0.0 + '@ai-sdk/google': ^4.0.0 + '@ai-sdk/mistral': ^4.0.0 + '@ai-sdk/openai': ^4.0.0 + '@huggingface/transformers': ^3.0.0 || ^4.0.0 '@libsql/client': ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 '@upstash/vector': ^1.0.0 - ai: ^4.0.0 || ^5.0.0 || ^6.0.0 - ollama-ai-provider-v2: ^1.0.0 || ^2.0.0 || ^3.0.0 + ai: ^7.0.0 + ollama-ai-provider-v2: ^4.0.0 pg: ^8.0.0 sqlite-vec: ^0.1.0-alpha.0 typescript: ^5.0.0 || ^6.0.0-0 @@ -7337,7 +7337,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retriv@0.14.7(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2): + retriv@0.15.0(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2): optionalDependencies: '@huggingface/transformers': 4.2.0 sqlite-vec: 0.1.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 698b7894..98298b4e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ catalogMode: prefer minimumReleaseAgeExclude: - verkit@0.2.0 - '@mdream/rust-wasm32-wasi@1.5.12' + - retriv@0.15.0 shellEmulator: true trustPolicy: no-downgrade @@ -23,7 +24,7 @@ catalog: ofetch: ^1.5.1 pathe: ^2.0.3 publint: ^0.3.23 - retriv: ^0.14.7 + retriv: ^0.15.0 std-env: ^4.2.0 tsx: ^4.23.8 typebox: ^1.3.10 From edcf20e54e46a35bd8baf17e0f0d74e69b637560 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 12 Aug 2026 16:17:40 +1000 Subject: [PATCH 5/6] fix(ci): keep TypeScript 6 API for ESLint --- package.json | 1 + packages/protocol/package.json | 1 + pnpm-lock.yaml | 160 +++++++++++++++++++-------------- pnpm-workspace.yaml | 3 +- src/agent/clis/executors.ts | 2 +- src/agent/clis/pi-ai-auth.ts | 12 ++- src/core/semver.ts | 1 - src/retriv/index-identity.ts | 2 +- 8 files changed, 106 insertions(+), 76 deletions(-) diff --git a/package.json b/package.json index 2f59b151..3dffe712 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@antfu/eslint-config": "catalog:dev-lint", "@types/node": "catalog:dev-build", + "@typescript/native": "catalog:", "@vitest/coverage-v8": "catalog:dev-test", "bumpp": "catalog:", "eslint": "catalog:dev-lint", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 061a82bc..de2eec79 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -59,6 +59,7 @@ "@antfu/eslint-config": "catalog:dev-lint", "@arethetypeswrong/cli": "catalog:", "@types/node": "catalog:dev-build", + "@typescript/native": "catalog:", "eslint": "catalog:dev-lint", "obuild": "catalog:dev-build", "publint": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb923ee9..360b9031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@typescript/native': + specifier: npm:typescript@7.0.2 + version: 7.0.2 bumpp: specifier: ^12.2.0 version: 12.2.0 @@ -55,8 +58,8 @@ catalogs: specifier: ^1.3.10 version: 1.3.10 typescript: - specifier: 7.0.2 - version: 7.0.2 + specifier: npm:@typescript/typescript6@^6.0.2 + version: 6.0.2 unagent: specifier: ^0.0.8 version: 0.0.8 @@ -167,7 +170,7 @@ importers: version: 2.0.3 retriv: specifier: 'catalog:' - version: 0.15.0(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2) + version: 0.15.0(@huggingface/transformers@4.2.0)(@typescript/typescript6@6.0.2)(sqlite-vec@0.1.9) skilld-protocol: specifier: workspace:* version: link:packages/protocol @@ -182,7 +185,7 @@ importers: version: 1.3.10 typescript: specifier: 'catalog:' - version: 7.0.2 + version: '@typescript/typescript6@6.0.2' unagent: specifier: 'catalog:' version: 0.0.8(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9) @@ -192,10 +195,13 @@ importers: devDependencies: '@antfu/eslint-config': specifier: catalog:dev-lint - version: 9.2.0(@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)(vitest@4.1.10) + version: 9.2.0(@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(vitest@4.1.10) '@types/node': specifier: catalog:dev-build version: 26.1.2 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 '@vitest/coverage-v8': specifier: catalog:dev-test version: 4.1.10(vitest@4.1.10) @@ -207,7 +213,7 @@ importers: version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) obuild: specifier: catalog:dev-build - version: 0.4.38(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4)(typescript@7.0.2) + version: 0.4.38(@typescript/typescript6@6.0.2)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4) tsx: specifier: 'catalog:' version: 4.23.8 @@ -227,25 +233,28 @@ importers: devDependencies: '@antfu/eslint-config': specifier: catalog:dev-lint - version: 9.2.0(@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)(vitest@4.1.10) + version: 9.2.0(@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(vitest@4.1.10) '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.5 '@types/node': specifier: catalog:dev-build version: 26.1.2 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint: specifier: catalog:dev-lint version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) obuild: specifier: catalog:dev-build - version: 0.4.38(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4)(typescript@7.0.2) + version: 0.4.38(@typescript/typescript6@6.0.2)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4) publint: specifier: 'catalog:' version: 0.3.23 typescript: specifier: 'catalog:' - version: 7.0.2 + version: '@typescript/typescript6@6.0.2' vitest: specifier: catalog:dev-test version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@26.1.2)(jiti@2.7.0)(tsx@4.23.8)(yaml@2.9.0)) @@ -1956,6 +1965,10 @@ packages: cpu: [x64] os: [win32] + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -3817,6 +3830,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -4126,7 +4144,7 @@ snapshots: '@andrewbranch/untar.js@1.0.4': {} - '@antfu/eslint-config@9.2.0(@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)(vitest@4.1.10)': + '@antfu/eslint-config@9.2.0(@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(vitest@4.1.10)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.7.0 @@ -4134,9 +4152,9 @@ snapshots: '@eslint-community/eslint-plugin-eslint-comments': 4.7.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint/markdown': 8.0.3(supports-color@7.2.0) '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) - '@vitest/eslint-plugin': 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)(vitest@4.1.10) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@vitest/eslint-plugin': 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(vitest@4.1.10) ansis: 4.3.1 cac: 7.0.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) @@ -4144,19 +4162,19 @@ snapshots: eslint-flat-config-utils: 3.2.0 eslint-merge-processors: 2.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-antfu: 3.2.3(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-command: 3.5.3(@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-command: 3.5.3(@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-import-lite: 0.6.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-jsdoc: 63.3.3(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-jsonc: 3.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-n: 18.2.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@7.0.2) + eslint-plugin-n: 18.2.2(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-perfectionist: 5.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + eslint-plugin-perfectionist: 5.10.1(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-pnpm: 1.7.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-regexp: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-toml: 1.5.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-unicorn: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-vue: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-vue: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)) eslint-plugin-yml: 3.8.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.27)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) globals: 17.9.0 @@ -5325,40 +5343,40 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) '@typescript-eslint/visitor-keys': 8.66.0 eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.66.0(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/project-service@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@7.0.2) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) '@typescript-eslint/types': 8.66.0 debug: 4.4.3(supports-color@7.2.0) - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -5367,47 +5385,47 @@ snapshots: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.66.0(typescript@7.0.2)': + '@typescript-eslint/tsconfig-utils@8.66.0(@typescript/typescript6@6.0.2)': dependencies: - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/type-utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@7.0.2) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0)': dependencies: - '@typescript-eslint/project-service': 8.66.0(supports-color@7.2.0)(typescript@7.0.2) - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@7.0.2) + '@typescript-eslint/project-service': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@7.0.2) - typescript: 7.0.2 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)': + '@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -5476,6 +5494,10 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5490,14 +5512,14 @@ snapshots: tinyrainbow: 3.1.1 vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@26.1.2)(jiti@2.7.0)(tsx@4.23.8)(yaml@2.9.0)) - '@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)(vitest@4.1.10)': + '@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(vitest@4.1.10)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) - typescript: 7.0.2 + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + typescript: '@typescript/typescript6@6.0.2' vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@26.1.2)(jiti@2.7.0)(tsx@4.23.8)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -6028,11 +6050,11 @@ snapshots: dependencies: eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@7.0.2))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: '@es-joy/jsdoccomment': 0.88.0 - '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@7.0.2) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@7.2.0) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) eslint-plugin-es-x@7.8.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): @@ -6081,7 +6103,7 @@ snapshots: transitivePeerDependencies: - '@eslint/json' - eslint-plugin-n@18.2.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@7.0.2): + eslint-plugin-n@18.2.2(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) enhanced-resolve: 5.24.5 @@ -6093,13 +6115,13 @@ snapshots: ignore: 5.3.2 semver: 7.8.5 optionalDependencies: - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' eslint-plugin-no-only-tests@3.4.0: {} - eslint-plugin-perfectionist@5.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2): + eslint-plugin-perfectionist@5.10.1(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -6163,13 +6185,13 @@ snapshots: strip-indent: 4.1.1 yaml: 2.9.0 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) - eslint-plugin-vue@10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)): + eslint-plugin-vue@10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) @@ -6181,7 +6203,7 @@ snapshots: xml-name-validator: 5.0.0 optionalDependencies: '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-yml@3.8.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: @@ -7040,7 +7062,7 @@ snapshots: obug@2.1.4: {} - obuild@0.4.38(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4)(typescript@7.0.2): + obuild@0.4.38(@typescript/typescript6@6.0.2)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4): dependencies: c12: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4) consola: 3.4.2 @@ -7049,7 +7071,7 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 rolldown: 1.2.3 - rolldown-plugin-dts: 0.27.14(rolldown@1.2.3)(typescript@7.0.2) + rolldown-plugin-dts: 0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.3) tinyglobby: 0.2.17 transitivePeerDependencies: - '@typescript/native-preview' @@ -7280,15 +7302,15 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retriv@0.15.0(@huggingface/transformers@4.2.0)(sqlite-vec@0.1.9)(typescript@7.0.2): + retriv@0.15.0(@huggingface/transformers@4.2.0)(@typescript/typescript6@6.0.2)(sqlite-vec@0.1.9): optionalDependencies: '@huggingface/transformers': 4.2.0 sqlite-vec: 0.1.9 - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' retry@0.13.1: {} - rolldown-plugin-dts@0.27.14(rolldown@1.2.3)(typescript@7.0.2): + rolldown-plugin-dts@0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.3): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 @@ -7298,7 +7320,7 @@ snapshots: yuku-codegen: 0.8.3 yuku-parser: 0.8.3 optionalDependencies: - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - oxc-resolver @@ -7545,9 +7567,9 @@ snapshots: ts-algebra@2.0.0: {} - ts-api-utils@2.5.0(typescript@7.0.2): + ts-api-utils@2.5.0(@typescript/typescript6@6.0.2): dependencies: - typescript: 7.0.2 + typescript: '@typescript/typescript6@6.0.2' tslib@2.8.1: {} @@ -7571,6 +7593,8 @@ snapshots: typescript@5.6.1-rc: {} + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98298b4e..6e5051b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: '@earendil-works/pi-ai': ^0.83.0 '@mdream/crawl': ^1.5.12 '@napi-rs/keyring': ^1.3.0 + '@typescript/native': npm:typescript@7.0.2 bumpp: ^12.2.0 giget: ^3.3.1 hookable: ^6.1.1 @@ -28,7 +29,7 @@ catalog: std-env: ^4.2.0 tsx: ^4.23.8 typebox: ^1.3.10 - typescript: 7.0.2 + typescript: npm:@typescript/typescript6@^6.0.2 unagent: ^0.0.8 verkit: ^0.3.2 zod: ^4.4.3 diff --git a/src/agent/clis/executors.ts b/src/agent/clis/executors.ts index 16e1d017..05a21f08 100644 --- a/src/agent/clis/executors.ts +++ b/src/agent/clis/executors.ts @@ -14,8 +14,8 @@ import type { OptimizeModel } from './types.ts' import { getSkillReferenceDirs } from '../../cache/index.ts' import { CLI_ADAPTERS, CLI_MODELS } from './index.ts' import { isOllamaModel, ollamaExecutor } from './ollama.ts' -import { getAvailablePiAiModels, isPiAiModel, optimizeSectionPiAi } from './pi-ai.ts' import { createPiAiModels } from './pi-ai-auth.ts' +import { getAvailablePiAiModels, isPiAiModel, optimizeSectionPiAi } from './pi-ai.ts' import { spawnCliAndStream } from './runner.ts' function cliExecutor(model: OptimizeModel): SectionExecutor | { error: string } { diff --git a/src/agent/clis/pi-ai-auth.ts b/src/agent/clis/pi-ai-auth.ts index 04de33db..fa35ecc3 100644 --- a/src/agent/clis/pi-ai-auth.ts +++ b/src/agent/clis/pi-ai-auth.ts @@ -149,16 +149,20 @@ export async function loginOAuthProvider(providerId: string, callbacks: LoginCal return false const notify = (event: AuthEvent): void => { - if (event.type === 'auth_url') + if (event.type === 'auth_url') { callbacks.onAuth(event.url, event.instructions) + } else if (event.type === 'device_code') { - if (callbacks.onDeviceCode) + if (callbacks.onDeviceCode) { callbacks.onDeviceCode(event.userCode, event.verificationUri) - else + } + else { callbacks.onAuth(event.verificationUri, `Enter code ${event.userCode}`) + } } - else + else { callbacks.onProgress?.(event.message) + } } const prompt = async (input: AuthPrompt): Promise => { if (input.type !== 'select') diff --git a/src/core/semver.ts b/src/core/semver.ts index 063a41e4..531db780 100644 --- a/src/core/semver.ts +++ b/src/core/semver.ts @@ -75,4 +75,3 @@ export function pickLatestTag(distTags?: Record): PickedTa return pool[0] ?? null } - diff --git a/src/retriv/index-identity.ts b/src/retriv/index-identity.ts index fa2aee50..88a45bf7 100644 --- a/src/retriv/index-identity.ts +++ b/src/retriv/index-identity.ts @@ -33,7 +33,7 @@ export function readIndexEmbeddingIdentity(dbPath: string): string | undefined { const db = new nodeSqlite.DatabaseSync(dbPath, { open: true, readOnly: true }) try { - const table = db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(META_TABLE) + const table = db.prepare('SELECT 1 FROM sqlite_schema WHERE type = \'table\' AND name = ?').get(META_TABLE) if (!table) return undefined const row = db.prepare(`SELECT value FROM ${META_TABLE} WHERE key = ?`).get(IDENTITY_KEY) as { value: string } | undefined From ea9a72e6c741d5bfeff09276dc1a6ef4d9d9ee0b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 12 Aug 2026 16:20:16 +1000 Subject: [PATCH 6/6] test(git-skills): create agent directory fixtures --- test/unit/git-skills.test.ts | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/test/unit/git-skills.test.ts b/test/unit/git-skills.test.ts index d031175a..99c9ba99 100644 --- a/test/unit/git-skills.test.ts +++ b/test/unit/git-skills.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'pathe' import { describe, expect, it } from 'vitest' @@ -209,19 +209,26 @@ author: someone }) it('discovers skills from agent-specific skill directories', async () => { - const fixture = join(__dirname, '../fixtures/mock-skills-repo-agent-dirs') - const { skills } = await fetchGitSkills({ type: 'local', localPath: fixture }) - - expect(skills.map(s => s.name).sort()).toEqual([ - 'agents-skill', - 'claude-skill', - 'github-skill', - ]) - expect(skills.map(s => s.path).sort()).toEqual([ - '.agents/skills/agents-skill', - '.claude/skills/claude-skill', - '.github/skills/github-skill', - ]) + const fixture = mkdtempSync(join(tmpdir(), 'skilld-agent-skill-dirs-')) + const expected = [ + { name: 'agents-skill', path: '.agents/skills/agents-skill' }, + { name: 'claude-skill', path: '.claude/skills/claude-skill' }, + { name: 'github-skill', path: '.github/skills/github-skill' }, + ] + for (const skill of expected) { + const skillDir = join(fixture, skill.path) + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${skill.name}\n---\n`) + } + + try { + const { skills } = await fetchGitSkills({ type: 'local', localPath: fixture }) + expect(skills.map(s => s.name).sort()).toEqual(expected.map(s => s.name)) + expect(skills.map(s => s.path).sort()).toEqual(expected.map(s => s.path)) + } + finally { + rmSync(fixture, { recursive: true, force: true }) + } }) })