@sdsrs/llm-wiki 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-07-12)
4
+
5
+ **Added:**
6
+
7
+ - **Local / offline embedding via transformers.js.** Set `"embeddingModel":
8
+ "local:Xenova/multilingual-e5-small"` in `~/.llm-wiki/config.json` and install the
9
+ optional `@huggingface/transformers` to generate and query vectors with no embedding
10
+ API. With only a local model configured, `embed` / `search` / `ask --retrieve-only`
11
+ run fully offline (answering with `ask` still needs a chat provider). The default
12
+ model is multilingual, so zh↔en cross-language retrieval is preserved. The dependency
13
+ is optional — the base install is unchanged.
14
+
15
+ ## 0.8.3 (2026-07-12)
16
+
17
+ Performance and test-hardening cleanup; no shipped behavior change. Suite 261 → 263.
18
+
19
+ **Changed:**
20
+
21
+ - **Retrieval parses the wiki page set once per query, not up to three times.** The
22
+ BM25 build, the vector-hit valid set, and the index-listing fallback's id set now
23
+ share one page cache keyed by the same mtime + size freshness token as the BM25
24
+ index (added in 0.8.2), so any add / edit / removal / invalidation still busts it
25
+ and retired knowledge can never resurface. On a long-lived MCP server a vector
26
+ (`auto`/`hybrid`) query drops its second full page parse; `locatePages` also reads
27
+ `wiki.config.json` once instead of twice.
28
+ - **The `mcp` command loads the MCP SDK + zod lazily.** They were imported at CLI
29
+ startup for every command; only `mcp` needs them. `scan` / `convert` / `ask` /
30
+ `init` and friends now skip ~200–250 ms of import cost.
31
+
32
+ **Internal (not shipped in the npm package):**
33
+
34
+ - Atomic-write regression guards: `saveManifest`, `saveVectorStore`, and the `scan`
35
+ plan write now assert the temp + rename path via a `fs.renameSync` spy — the old
36
+ `.tmp`-absence check alone also passed for a direct truncating write (mem #10097).
37
+ - The full eval report (`scripts/eval/run-all.mjs`) judges the shipped default
38
+ retrieval mode: answer-level head-to-head now defaults to `bm25,auto` (was the
39
+ hardcoded `bm25,hybrid`) and takes an `--answer-arms` flag.
40
+
3
41
  ## 0.8.2 (2026-07-12)
4
42
 
5
43
  Performance, no behavior change. Suite 260 → 261.
package/README.md CHANGED
@@ -144,6 +144,22 @@ similarity (RRF); `--retrieve-only` labels each hit `[bm25]`, `[vector]` or
144
144
  `[bm25+vector]`. Vectors only *locate* pages — context is still whole pages,
145
145
  never chunks. Any vector-path failure falls back to BM25 with a warning.
146
146
 
147
+ ### Local / offline embedding
148
+
149
+ Vector retrieval normally calls an embedding API. To run it locally with no API key:
150
+
151
+ 1. Install the optional model runtime: `npm i @huggingface/transformers`
152
+ 2. In `~/.llm-wiki/config.json`, set a `local:` embedding model:
153
+ ```json
154
+ { "embeddingModel": "local:Xenova/multilingual-e5-small" }
155
+ ```
156
+ 3. `llm-wiki embed --kb ./kb` — the first run downloads the model (~120 MB).
157
+
158
+ The default `Xenova/multilingual-e5-small` is multilingual, so cross-language
159
+ (zh↔en) retrieval keeps working. With only a local embedding model configured,
160
+ `embed`, `search`, and `ask --retrieve-only` run fully offline; generating an
161
+ answer with `ask` still needs a chat provider.
162
+
147
163
  ### Graph queries
148
164
 
149
165
  `graph.json` (rebuilt by `index`) is a link graph over the pages — wikilinks,
package/bin/llm-wiki.mjs CHANGED
@@ -14,7 +14,6 @@ import { statusKb } from '../src/status.mjs'
14
14
  import { connectProject, installSkills } from '../src/connect.mjs'
15
15
  import { exportGraph, loadGraph, exportMarkdownPages } from '../src/export.mjs'
16
16
  import { shortestPath, neighborhood, hubs } from '../src/graph.mjs'
17
- import { runMcpServer } from '../src/mcp.mjs'
18
17
 
19
18
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
20
19
 
@@ -203,6 +202,9 @@ program.command('mcp')
203
202
  .description('run a read-only MCP server (stdio) over the knowledge base')
204
203
  .option('--kb <dir>', 'knowledge base root', '.')
205
204
  .action(async (opts) => {
205
+ // Lazy import: mcp.mjs pulls in the MCP SDK + zod, which no other command needs.
206
+ // Static-importing it would load them on every scan/convert/ask invocation.
207
+ const { runMcpServer } = await import('../src/mcp.mjs')
206
208
  // stdout belongs to the MCP transport from here on — no console.log.
207
209
  await runMcpServer(opts.kb)
208
210
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Compile messy document directories into Karpathy-style llm_wiki knowledge bases — standalone Q&A CLI + Claude Code/Codex skills",
5
5
  "directories": {
6
6
  "test": "test"
@@ -51,5 +51,13 @@
51
51
  "undici": "^8.7.0",
52
52
  "yaml": "^2.9.0",
53
53
  "zod": "^4.4.3"
54
+ },
55
+ "peerDependencies": {
56
+ "@huggingface/transformers": "^3.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@huggingface/transformers": {
60
+ "optional": true
61
+ }
54
62
  }
55
63
  }
package/src/ask.mjs CHANGED
@@ -5,29 +5,26 @@ import { loadKbConfig } from './templates.mjs'
5
5
  import { listWikiPages, isInvalidated, asList, PAGE_DIRS } from './pages.mjs'
6
6
  import { buildBm25Index, searchBm25 } from './bm25.mjs'
7
7
  import { worstCaseTokens } from './scanner.mjs'
8
- import { loadLlmConfig, makeTransport } from './llm-config.mjs'
8
+ import { loadLlmConfig, loadEmbedConfig, makeTransport } from './llm-config.mjs'
9
9
  import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
10
10
  import { embedTexts } from './embed.mjs'
11
11
  import { fetchWithRetry } from './retry.mjs'
12
12
 
13
- // One built BM25 index per KB root, reused across queries while the wiki pages and
14
- // title-weight config are unchanged. The cost being cached is reading + tokenizing
15
- // every page: a CLI process builds it once and exits (cache is transparent there), but
16
- // a long-lived MCP server otherwise rebuilds from disk on every wiki_search. Bounded so
17
- // a process serving many KBs can't grow it without limit.
18
- const bm25Cache = new Map()
19
- const BM25_CACHE_MAX = 8
13
+ // Two per-KB caches, both keyed by a cheap page-set freshness token so neither can
14
+ // serve a stale view. The token is each live page file's mtime+size: any page
15
+ // add / remove / in-place edit / invalidation (a frontmatter status change IS a
16
+ // content change) flips it and forces a rebuild. Statting the page set is far cheaper
17
+ // than reading + parsing it; MCP is read-only and never mutates pages while serving, so
18
+ // within one server's lifetime pages change only via a separate build process (which
19
+ // rewrites files with new mtimes). Both caches are bounded so a process serving many
20
+ // KBs can't grow them without limit.
21
+ const bm25Cache = new Map() // kbRoot -> { token: `${pagesToken}|w=${w}`, idx }
22
+ const pagesCache = new Map() // kbRoot -> { token: pagesToken, pages }
23
+ const CACHE_MAX = 8
20
24
 
21
- // Cheap freshness token: each live page file's mtime+size plus the title weight. Any
22
- // page add / remove / in-place edit / invalidation (a frontmatter status change IS a
23
- // content change) or config change flips the token and forces a rebuild — so the cache
24
- // can never serve a stale index. Statting the page set is far cheaper than reading and
25
- // tokenizing it; MCP is read-only and never mutates pages while serving, so within one
26
- // server's lifetime pages change only via a separate build process (which rewrites
27
- // files with new mtimes).
28
- function pageSetToken(kbRoot, w) {
25
+ function pagesToken(kbRoot) {
29
26
  const wiki = kbPaths(kbRoot).wiki
30
- const parts = [`w=${w}`]
27
+ const parts = []
31
28
  for (const dir of PAGE_DIRS) {
32
29
  let entries
33
30
  try { entries = fs.readdirSync(path.join(wiki, dir)) } catch { continue }
@@ -42,6 +39,23 @@ function pageSetToken(kbRoot, w) {
42
39
  return parts.join('|')
43
40
  }
44
41
 
42
+ // The parsed, filtered live-page list — the dominant per-query cost (listWikiPages
43
+ // reads + frontmatter-parses every page), shared by the BM25 build, the vector-hit
44
+ // valid set, and the listing fallback's validIds. Excluding invalidated pages here is
45
+ // the query-time exclusion every consumer needs, done once. Callers on the same query
46
+ // pass the token already computed for the BM25 cache; standalone callers let it default.
47
+ function livePages(kbRoot, token = pagesToken(kbRoot)) {
48
+ let entry = pagesCache.get(kbRoot)
49
+ if (!entry || entry.token !== token) {
50
+ const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
51
+ pagesCache.delete(kbRoot) // re-insert so this KB becomes most-recent for LRU eviction
52
+ pagesCache.set(kbRoot, { token, pages })
53
+ while (pagesCache.size > CACHE_MAX) pagesCache.delete(pagesCache.keys().next().value)
54
+ entry = pagesCache.get(kbRoot)
55
+ }
56
+ return entry.pages
57
+ }
58
+
45
59
  export function retrievePages(kbRoot, question, k = 6) {
46
60
  // Title is a field: repeat it bm25TitleWeight times in the indexed text so a
47
61
  // title term outweighs the same term buried in the body (measured +0.04 Recall@5
@@ -50,17 +64,17 @@ export function retrievePages(kbRoot, question, k = 6) {
50
64
  // loadKbConfig guarantees a finite integer >= 1; cap the upper bound so a large
51
65
  // value can't materialize a giant per-page array (Array(w).fill) and OOM retrieval.
52
66
  const w = Math.min(25, loadKbConfig(kbRoot).bm25TitleWeight)
53
- const token = pageSetToken(kbRoot, w)
67
+ const token = pagesToken(kbRoot)
68
+ const bmKey = `${token}|w=${w}` // title weight changes the indexed text but not the page list
54
69
  let entry = bm25Cache.get(kbRoot)
55
- if (!entry || entry.token !== token) {
56
- const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
57
- const idx = buildBm25Index(pages.map(p => ({
70
+ if (!entry || entry.token !== bmKey) {
71
+ const idx = buildBm25Index(livePages(kbRoot, token).map(p => ({
58
72
  id: p.relPath,
59
73
  text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
60
74
  })))
61
75
  bm25Cache.delete(kbRoot) // re-insert so this KB becomes most-recent for LRU eviction
62
- bm25Cache.set(kbRoot, { token, idx })
63
- while (bm25Cache.size > BM25_CACHE_MAX) bm25Cache.delete(bm25Cache.keys().next().value)
76
+ bm25Cache.set(kbRoot, { token: bmKey, idx })
77
+ while (bm25Cache.size > CACHE_MAX) bm25Cache.delete(bm25Cache.keys().next().value)
64
78
  entry = bm25Cache.get(kbRoot)
65
79
  }
66
80
  return searchBm25(entry.idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
@@ -110,7 +124,7 @@ export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {})
110
124
  // 'bm25': lexical only, returns before any vector access.
111
125
  // 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
112
126
  // vector error) throws instead of degrading, and vectorEnabled is ignored.
113
- export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry } = {}) {
127
+ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
114
128
  if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
115
129
  const bm25 = retrievePages(kbRoot, question, k)
116
130
  const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
@@ -121,27 +135,32 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
121
135
  if (retrieval === 'hybrid') throw new Error(`retrieval 'hybrid' unavailable: ${what}`)
122
136
  return asBm25()
123
137
  }
124
- if (retrieval === 'auto' && !loadKbConfig(kbRoot).vectorEnabled) return asBm25()
138
+ // One config read for both the vectorEnabled gate and the lexicalGuard flag below.
139
+ const kbCfg = loadKbConfig(kbRoot)
140
+ if (retrieval === 'auto' && !kbCfg.vectorEnabled) return asBm25()
125
141
  const store = loadVectorStore(kbRoot)
126
142
  if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
127
- const cfg = loadLlmConfig(kbRoot)
143
+ const cfg = loadEmbedConfig(kbRoot)
128
144
  if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
129
145
  // A store built by a different embeddingModel lives in a foreign vector space:
130
146
  // fusing it produces silent garbage, so treat it as missing (not a failure).
131
147
  if (store.model !== cfg.embeddingModel) return unavailable(`vector store was built with "${store.model}" but config says "${cfg.embeddingModel}" — re-run \`llm-wiki embed\``)
132
148
  try {
133
- const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
134
- const [qv] = await embedTexts(cfg, t, [question])
149
+ const injected = fetchImpl || pipelineFactory
150
+ const t = injected
151
+ ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
152
+ : { ...(await makeTransport()), retry }
153
+ const [qv] = await embedTexts(cfg, t, [question], { role: 'query' })
135
154
  const qn = normalize(qv)
136
155
  const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
137
156
  // The store is a snapshot from the last embed; pages invalidated, deleted, or
138
157
  // renamed since then still have vectors. Keep only hits that map to a live,
139
158
  // non-invalidated page so retired knowledge cannot resurface (and askKb never
140
159
  // ENOENTs reading a vanished file).
141
- const valid = new Set(listWikiPages(kbRoot).filter(pg => !pg.error && !isInvalidated(pg)).map(pg => pg.relPath))
160
+ const valid = new Set(livePages(kbRoot).map(pg => pg.relPath))
142
161
  const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
143
162
  const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
144
- { lexicalGuard: retrieval === 'auto' && loadKbConfig(kbRoot).lexicalGuard })
163
+ { lexicalGuard: retrieval === 'auto' && kbCfg.lexicalGuard })
145
164
  return { hits, usedVector: true, guardApplied }
146
165
  } catch (err) {
147
166
  if (retrieval === 'hybrid') throw err
@@ -210,15 +229,13 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
210
229
  return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
211
230
  }
212
231
 
213
- export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry } = {}) {
232
+ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
214
233
  const p = kbPaths(kbRoot)
215
- let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry })
234
+ let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
216
235
  if (retrieveOnly) return { pages: hits, answer: null }
217
236
  let validIds
218
237
  if (hits.length === 0) {
219
- validIds = new Set(listWikiPages(kbRoot)
220
- .filter(pg => !pg.error && !isInvalidated(pg))
221
- .map(pg => pg.relPath.replace(/\.md$/, '')))
238
+ validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
222
239
  if (validIds.size === 0) throw new Error('No relevant pages found — the knowledge base has no valid pages.')
223
240
  }
224
241
  const cfg = loadLlmConfig(kbRoot)
package/src/embed.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { listWikiPages, isInvalidated } from './pages.mjs'
2
- import { loadLlmConfig, makeTransport } from './llm-config.mjs'
2
+ import { loadEmbedConfig, makeTransport } from './llm-config.mjs'
3
3
  import { sha256Text } from './hashing.mjs'
4
4
  import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
5
5
  import { fetchWithRetry } from './retry.mjs'
6
+ import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
6
7
 
7
8
  const BATCH = 64
8
9
  // Safety margin under the 8192-token input limit of common embedding models
@@ -44,7 +45,10 @@ function capEmbedText(text) {
44
45
  return text.slice(0, lo)
45
46
  }
46
47
 
47
- export async function embedTexts(cfg, t, texts) {
48
+ export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
49
+ if (isLocalModel(cfg.embeddingModel)) {
50
+ return embedLocal(stripLocalPrefix(cfg.embeddingModel), texts, { role, pipelineFactory: t?.pipelineFactory })
51
+ }
48
52
  const url = `${cfg.baseURL.replace(/\/$/, '')}/embeddings`
49
53
  let res
50
54
  try {
@@ -70,10 +74,9 @@ export async function embedTexts(cfg, t, texts) {
70
74
  return [...data.data].sort((a, b) => a.index - b.index).map(d => d.embedding)
71
75
  }
72
76
 
73
- export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
74
- const cfg = loadLlmConfig(kbRoot)
75
- if (!cfg) throw new Error('No LLM configured. Create ~/.llm-wiki/config.json (OpenAI-compatible).')
76
- if (!cfg.embeddingModel) throw new Error('No embedding model configured. Add "embeddingModel" to your provider (or flat config) in ~/.llm-wiki/config.json, e.g. "text-embedding-3-small".')
77
+ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
78
+ const cfg = loadEmbedConfig(kbRoot)
79
+ if (!cfg?.embeddingModel) throw new Error('No usable embedding config. A remote embeddingModel needs chat credentials (baseURL/apiKey) in ~/.llm-wiki/config.json; a local one needs none — set "embeddingModel": "local:Xenova/multilingual-e5-small".')
77
80
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
78
81
  const prev = loadVectorStore(kbRoot)
79
82
  const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
@@ -92,7 +95,10 @@ export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
92
95
  let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
93
96
  let embedded = 0
94
97
  if (jobs.length > 0) {
95
- const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
98
+ const injected = fetchImpl || pipelineFactory
99
+ const t = injected
100
+ ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
101
+ : { ...(await makeTransport()), retry }
96
102
  for (let i = 0; i < jobs.length; i += BATCH) {
97
103
  const batch = jobs.slice(i, i + BATCH)
98
104
  const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
@@ -65,6 +65,24 @@ export function loadLlmConfig(kbRoot) {
65
65
  return cfg
66
66
  }
67
67
 
68
+ // The embedding path (embedKb, locatePages' vector branch) needs an embeddingModel
69
+ // but not necessarily chat credentials: a LOCAL model runs with no baseURL/apiKey at
70
+ // all, so a fully-offline KB (local embeddings, no chat provider) can still embed +
71
+ // search. A REMOTE embeddingModel still needs the chat provider's baseURL/apiKey to
72
+ // call the API, so it resolves only when loadLlmConfig does. loadLlmConfig's own
73
+ // (chat-complete-or-null) contract is unchanged.
74
+ export function loadEmbedConfig(kbRoot) {
75
+ const chat = loadLlmConfig(kbRoot)
76
+ if (chat?.embeddingModel) return chat
77
+ const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
78
+ const globalFile = path.join(dir, 'config.json')
79
+ if (!fs.existsSync(globalFile)) return null
80
+ const fileCfg = readJsonFile(globalFile, { redactContents: true })
81
+ const em = fileCfg.embeddingModel
82
+ ?? Object.values(fileCfg.providers ?? {}).map(p => p?.embeddingModel).find(Boolean)
83
+ return (typeof em === 'string' && em.startsWith('local:')) ? { embeddingModel: em } : null
84
+ }
85
+
68
86
  // Node's built-in fetch rejects the npm undici package's dispatcher
69
87
  // ("invalid onRequestStart method": handler interface mismatch between
70
88
  // undici 8.x and Node's bundled undici), so when a proxy is configured we
@@ -0,0 +1,72 @@
1
+ // Local/offline embedding via transformers.js. The heavy dependency
2
+ // (@huggingface/transformers, ~253 MB installed) is an optional peer dependency,
3
+ // imported ONLY here and ONLY when a `local:` embeddingModel is used — the base
4
+ // install stays lean. (Separate from the ~120 MB first-run model download noted in
5
+ // the README — that's the model weights, not this package.) The pipeline factory is
6
+ // injectable so tests never touch the real dependency.
7
+
8
+ const LOCAL_PREFIX = 'local:'
9
+
10
+ export function isLocalModel(m) {
11
+ return typeof m === 'string' && m.startsWith(LOCAL_PREFIX)
12
+ }
13
+
14
+ export function stripLocalPrefix(m) {
15
+ return m.slice(LOCAL_PREFIX.length)
16
+ }
17
+
18
+ // A missing optional dependency surfaces as ERR_MODULE_NOT_FOUND — turn it into an
19
+ // actionable instruction instead of an opaque stack. Other errors pass through.
20
+ export function friendlyImportError(err) {
21
+ if (err?.code === 'ERR_MODULE_NOT_FOUND') {
22
+ return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
23
+ }
24
+ return err
25
+ }
26
+
27
+ // e5-family models are trained with asymmetric "query:" / "passage:" instruction
28
+ // prefixes; retrieval quality drops materially without them. Other families
29
+ // (e.g. paraphrase-multilingual) encode symmetrically and must NOT get a prefix.
30
+ function applyRolePrefix(model, texts, role) {
31
+ if (!/e5/i.test(model)) return texts
32
+ const tag = role === 'query' ? 'query: ' : 'passage: '
33
+ return texts.map(t => tag + t)
34
+ }
35
+
36
+ // Default factory: dynamically import transformers.js and build a feature-extraction
37
+ // pipeline. Mean-pooled, UN-normalized — the shared normalize() in embed/ask applies
38
+ // the unit-length step, identical to the API path.
39
+ async function defaultPipelineFactory(model) {
40
+ let transformers
41
+ try {
42
+ transformers = await import('@huggingface/transformers')
43
+ } catch (err) {
44
+ throw friendlyImportError(err)
45
+ }
46
+ const extractor = await transformers.pipeline('feature-extraction', model)
47
+ return async (texts) => {
48
+ const out = await extractor(texts, { pooling: 'mean', normalize: false })
49
+ return out.tolist() // number[][], one row per input, in order
50
+ }
51
+ }
52
+
53
+ // Cache the real (expensive) per-model load across a batch run / long-lived MCP
54
+ // server. An injected factory (tests) bypasses the cache so tests stay isolated.
55
+ const pipelineCache = new Map() // model -> Promise<embedFn>
56
+
57
+ export async function embedLocal(model, texts, { role = 'passage', pipelineFactory } = {}) {
58
+ const prepared = applyRolePrefix(model, texts, role)
59
+ if (pipelineFactory) {
60
+ const embedFn = await pipelineFactory(model)
61
+ return embedFn(prepared)
62
+ }
63
+ let entry = pipelineCache.get(model)
64
+ if (!entry) {
65
+ entry = defaultPipelineFactory(model)
66
+ pipelineCache.set(model, entry)
67
+ }
68
+ let embedFn
69
+ try { embedFn = await entry }
70
+ catch (err) { pipelineCache.delete(model); throw err } // don't cache a failed load
71
+ return embedFn(prepared)
72
+ }