@sdsrs/llm-wiki 0.8.3 → 0.9.1

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 (2026-07-12)
4
+
5
+ Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
6
+ vectors produced. Suite 271 → 272.
7
+
8
+ **Fixed:**
9
+
10
+ - **Long pages no longer truncate silently on local models.** A local model
11
+ (e.g. `local:Xenova/multilingual-e5-small`) has a ~512-token input window and its
12
+ pipeline silently truncates longer pages to a head-only vector. `embed` now warns per
13
+ page and reports a `truncated N` count in its summary, so the head-only truncation is
14
+ visible instead of silent. The vector produced is unchanged.
15
+ - **A broken transitive dependency is no longer masked as "install
16
+ @huggingface/transformers".** The missing-dependency hint now fires only when
17
+ transformers.js itself is absent; when transformers.js is installed but one of its
18
+ (native) deps fails to load, the original error naming the real culprit passes through.
19
+ - **Clearer `embed` error when no embedding model is configured** — it now names the
20
+ actual fix (set `embeddingModel`) for both remote and local, instead of telling a user
21
+ who already has chat credentials to add credentials.
22
+
23
+ ## 0.9.0 (2026-07-12)
24
+
25
+ **Added:**
26
+
27
+ - **Local / offline embedding via transformers.js.** Set `"embeddingModel":
28
+ "local:Xenova/multilingual-e5-small"` in `~/.llm-wiki/config.json` and install the
29
+ optional `@huggingface/transformers` to generate and query vectors with no embedding
30
+ API. With only a local model configured, `embed` / `search` / `ask --retrieve-only`
31
+ run fully offline (answering with `ask` still needs a chat provider). The default
32
+ model is multilingual, so zh↔en cross-language retrieval is preserved. The dependency
33
+ is optional — the base install is unchanged.
34
+
3
35
  ## 0.8.3 (2026-07-12)
4
36
 
5
37
  Performance and test-hardening cleanup; no shipped behavior change. Suite 261 → 263.
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
@@ -77,7 +77,8 @@ program.command('embed')
77
77
  .option('--kb <dir>', 'knowledge base root', '.')
78
78
  .action(async (opts) => {
79
79
  const r = await embedKb(opts.kb)
80
- console.log(`embedded ${r.embedded}, reused ${r.reused}, pruned ${r.pruned} (model ${r.model}, dim ${r.dim})`)
80
+ const trunc = r.truncated ? `, truncated ${r.truncated}` : ''
81
+ console.log(`embedded ${r.embedded}, reused ${r.reused}, pruned ${r.pruned}${trunc} (model ${r.model}, dim ${r.dim})`)
81
82
  })
82
83
 
83
84
  program.command('ask <question>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
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,7 +5,7 @@ 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'
@@ -124,7 +124,7 @@ export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {})
124
124
  // 'bm25': lexical only, returns before any vector access.
125
125
  // 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
126
126
  // vector error) throws instead of degrading, and vectorEnabled is ignored.
127
- 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 } = {}) {
128
128
  if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
129
129
  const bm25 = retrievePages(kbRoot, question, k)
130
130
  const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
@@ -140,14 +140,17 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
140
140
  if (retrieval === 'auto' && !kbCfg.vectorEnabled) return asBm25()
141
141
  const store = loadVectorStore(kbRoot)
142
142
  if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
143
- const cfg = loadLlmConfig(kbRoot)
143
+ const cfg = loadEmbedConfig(kbRoot)
144
144
  if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
145
145
  // A store built by a different embeddingModel lives in a foreign vector space:
146
146
  // fusing it produces silent garbage, so treat it as missing (not a failure).
147
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\``)
148
148
  try {
149
- const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
150
- 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' })
151
154
  const qn = normalize(qv)
152
155
  const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
153
156
  // The store is a snapshot from the last embed; pages invalidated, deleted, or
@@ -226,9 +229,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
226
229
  return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
227
230
  }
228
231
 
229
- 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 } = {}) {
230
233
  const p = kbPaths(kbRoot)
231
- let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry })
234
+ let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
232
235
  if (retrieveOnly) return { pages: hits, answer: null }
233
236
  let validIds
234
237
  if (hits.length === 0) {
package/src/embed.mjs CHANGED
@@ -1,8 +1,10 @@
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 { estimateTokens } from './scanner.mjs'
7
+ import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
6
8
 
7
9
  const BATCH = 64
8
10
  // Safety margin under the 8192-token input limit of common embedding models
@@ -10,6 +12,15 @@ const BATCH = 64
10
12
  // whole-page retrieval is unaffected — the page file and its BM25 text are never
11
13
  // touched, only the vector's input is truncated so one huge page can't abort the run.
12
14
  const EMBED_TOKEN_CAP = 8000
15
+ // Local (transformers.js) models don't reject over-limit input the way an API does —
16
+ // the feature-extraction pipeline silently truncates at the model's own window, so a
17
+ // page over it yields a head-only vector with NO error. e5-small and most Xenova
18
+ // sentence-transformers are 512 tokens; we keep the 8000 cap on the text we send (it
19
+ // bounds compute and stays well above the window so the pipeline still fills it) and
20
+ // warn separately, via a realistic token estimate, whenever a page crosses this window
21
+ // so the head-only truncation is visible instead of silent. A model with a different
22
+ // window would extend this into a per-model lookup.
23
+ const LOCAL_TOKEN_WINDOW = 512
13
24
 
14
25
  // Worst-case token count for the embedding API — deliberately NOT scanner's
15
26
  // estimateTokens (chars/4), which underestimates dense markdown/code where real
@@ -44,7 +55,10 @@ function capEmbedText(text) {
44
55
  return text.slice(0, lo)
45
56
  }
46
57
 
47
- export async function embedTexts(cfg, t, texts) {
58
+ export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
59
+ if (isLocalModel(cfg.embeddingModel)) {
60
+ return embedLocal(stripLocalPrefix(cfg.embeddingModel), texts, { role, pipelineFactory: t?.pipelineFactory })
61
+ }
48
62
  const url = `${cfg.baseURL.replace(/\/$/, '')}/embeddings`
49
63
  let res
50
64
  try {
@@ -70,29 +84,43 @@ export async function embedTexts(cfg, t, texts) {
70
84
  return [...data.data].sort((a, b) => a.index - b.index).map(d => d.embedding)
71
85
  }
72
86
 
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".')
87
+ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
88
+ const cfg = loadEmbedConfig(kbRoot)
89
+ if (!cfg?.embeddingModel) throw new Error('No embedding model configured. Set "embeddingModel" in ~/.llm-wiki/config.json — a remote one (e.g. "text-embedding-3-small") also needs the provider baseURL/apiKey; a local one (e.g. "local:Xenova/multilingual-e5-small") needs no credentials.')
90
+ const local = isLocalModel(cfg.embeddingModel)
77
91
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
78
92
  const prev = loadVectorStore(kbRoot)
79
93
  const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
80
94
  const jobs = []
81
95
  const nextPages = {}
82
96
  let reused = 0
97
+ let truncatedCount = 0
83
98
  for (const pg of pages) {
84
99
  const raw = pageEmbedText(pg)
85
100
  const text = capEmbedText(raw)
86
- const truncated = text !== raw
101
+ // API path: `truncated` means our worst-case cap actually cut the text (the API
102
+ // would otherwise reject it). Local path: the cap rarely fires (window ≪ 8000), so
103
+ // detect the model's OWN silent truncation with a realistic estimate against its
104
+ // window — that's what produces a head-only vector the user should know about.
105
+ const overWindow = local && estimateTokens(raw) > LOCAL_TOKEN_WINDOW
106
+ const truncated = overWindow || (!local && text !== raw)
87
107
  const hash = sha256Text(text)
88
108
  if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
89
- if (truncated) process.stderr.write(`warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
109
+ if (truncated) {
110
+ truncatedCount++
111
+ process.stderr.write(overWindow
112
+ ? `warning: ${pg.relPath} exceeds the local embedding model's ~${LOCAL_TOKEN_WINDOW}-token window — its vector covers only the page head\n`
113
+ : `warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
114
+ }
90
115
  jobs.push({ relPath: pg.relPath, text, hash })
91
116
  }
92
117
  let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
93
118
  let embedded = 0
94
119
  if (jobs.length > 0) {
95
- const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
120
+ const injected = fetchImpl || pipelineFactory
121
+ const t = injected
122
+ ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
123
+ : { ...(await makeTransport()), retry }
96
124
  for (let i = 0; i < jobs.length; i += BATCH) {
97
125
  const batch = jobs.slice(i, i + BATCH)
98
126
  const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
@@ -113,5 +141,5 @@ export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
113
141
  const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
114
142
  const store = { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages }
115
143
  saveVectorStore(kbRoot, store)
116
- return { embedded, reused, pruned, model: cfg.embeddingModel, dim: store.dim }
144
+ return { embedded, reused, pruned, truncated: truncatedCount, model: cfg.embeddingModel, dim: store.dim }
117
145
  }
@@ -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,83 @@
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. Only map it when the ABSENT
20
+ // module is transformers.js itself: the same code fires when transformers.js IS
21
+ // installed but one of its (transitive/native) deps fails to resolve, and telling the
22
+ // user to re-install transformers then hides the real missing module. Pass those
23
+ // through so their message names the actual culprit.
24
+ export function friendlyImportError(err) {
25
+ if (err?.code !== 'ERR_MODULE_NOT_FOUND') return err
26
+ // Gate on the MISSING package — the quoted name after "Cannot find package/module" —
27
+ // NOT a substring of the whole message. A transitive failure names its real culprit
28
+ // in the quotes (e.g. 'onnxruntime-node') yet still mentions "@huggingface/transformers"
29
+ // in the "imported from <path>" tail, so a whole-message test mis-maps it. Rewrite only
30
+ // when the absent module IS transformers.js (the package root or a subpath import of it).
31
+ const missing = /Cannot find (?:package|module) '([^']+)'/.exec(err.message ?? '')?.[1]
32
+ if (missing && /^@huggingface\/transformers(\/|$)/.test(missing)) {
33
+ return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
34
+ }
35
+ return err
36
+ }
37
+
38
+ // e5-family models are trained with asymmetric "query:" / "passage:" instruction
39
+ // prefixes; retrieval quality drops materially without them. Other families
40
+ // (e.g. paraphrase-multilingual) encode symmetrically and must NOT get a prefix.
41
+ function applyRolePrefix(model, texts, role) {
42
+ if (!/e5/i.test(model)) return texts
43
+ const tag = role === 'query' ? 'query: ' : 'passage: '
44
+ return texts.map(t => tag + t)
45
+ }
46
+
47
+ // Default factory: dynamically import transformers.js and build a feature-extraction
48
+ // pipeline. Mean-pooled, UN-normalized — the shared normalize() in embed/ask applies
49
+ // the unit-length step, identical to the API path.
50
+ async function defaultPipelineFactory(model) {
51
+ let transformers
52
+ try {
53
+ transformers = await import('@huggingface/transformers')
54
+ } catch (err) {
55
+ throw friendlyImportError(err)
56
+ }
57
+ const extractor = await transformers.pipeline('feature-extraction', model)
58
+ return async (texts) => {
59
+ const out = await extractor(texts, { pooling: 'mean', normalize: false })
60
+ return out.tolist() // number[][], one row per input, in order
61
+ }
62
+ }
63
+
64
+ // Cache the real (expensive) per-model load across a batch run / long-lived MCP
65
+ // server. An injected factory (tests) bypasses the cache so tests stay isolated.
66
+ const pipelineCache = new Map() // model -> Promise<embedFn>
67
+
68
+ export async function embedLocal(model, texts, { role = 'passage', pipelineFactory } = {}) {
69
+ const prepared = applyRolePrefix(model, texts, role)
70
+ if (pipelineFactory) {
71
+ const embedFn = await pipelineFactory(model)
72
+ return embedFn(prepared)
73
+ }
74
+ let entry = pipelineCache.get(model)
75
+ if (!entry) {
76
+ entry = defaultPipelineFactory(model)
77
+ pipelineCache.set(model, entry)
78
+ }
79
+ let embedFn
80
+ try { embedFn = await entry }
81
+ catch (err) { pipelineCache.delete(model); throw err } // don't cache a failed load
82
+ return embedFn(prepared)
83
+ }