@sdsrs/llm-wiki 0.9.0 → 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,25 @@
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
+
3
23
  ## 0.9.0 (2026-07-12)
4
24
 
5
25
  **Added:**
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.9.0",
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"
package/src/embed.mjs CHANGED
@@ -3,6 +3,7 @@ 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'
6
7
  import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
7
8
 
8
9
  const BATCH = 64
@@ -11,6 +12,15 @@ const BATCH = 64
11
12
  // whole-page retrieval is unaffected — the page file and its BM25 text are never
12
13
  // touched, only the vector's input is truncated so one huge page can't abort the run.
13
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
14
24
 
15
25
  // Worst-case token count for the embedding API — deliberately NOT scanner's
16
26
  // estimateTokens (chars/4), which underestimates dense markdown/code where real
@@ -76,20 +86,32 @@ export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
76
86
 
77
87
  export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
78
88
  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".')
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)
80
91
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
81
92
  const prev = loadVectorStore(kbRoot)
82
93
  const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
83
94
  const jobs = []
84
95
  const nextPages = {}
85
96
  let reused = 0
97
+ let truncatedCount = 0
86
98
  for (const pg of pages) {
87
99
  const raw = pageEmbedText(pg)
88
100
  const text = capEmbedText(raw)
89
- 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)
90
107
  const hash = sha256Text(text)
91
108
  if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
92
- 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
+ }
93
115
  jobs.push({ relPath: pg.relPath, text, hash })
94
116
  }
95
117
  let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
@@ -119,5 +141,5 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
119
141
  const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
120
142
  const store = { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages }
121
143
  saveVectorStore(kbRoot, store)
122
- return { embedded, reused, pruned, model: cfg.embeddingModel, dim: store.dim }
144
+ return { embedded, reused, pruned, truncated: truncatedCount, model: cfg.embeddingModel, dim: store.dim }
123
145
  }
@@ -16,9 +16,20 @@ export function stripLocalPrefix(m) {
16
16
  }
17
17
 
18
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.
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.
20
24
  export function friendlyImportError(err) {
21
- if (err?.code === 'ERR_MODULE_NOT_FOUND') {
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)) {
22
33
  return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
23
34
  }
24
35
  return err