@sdsrs/llm-wiki 0.9.0 → 0.9.2

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,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 (2026-07-12)
4
+
5
+ Robustness and UX fixes from an end-to-end usage audit; no change to core retrieval or
6
+ convert behavior. Suite 271 → 279.
7
+
8
+ **Fixed:**
9
+
10
+ - **Concurrent writes to a derived file no longer crash.** `writeFileAtomic` used a fixed
11
+ temp name, so two writers to the same file (concurrent `index`, or the MCP server and a
12
+ CLI command touching the same derived file) collided — the second renamed a temp the
13
+ first had already consumed and died with ENOENT. Each write now uses a unique temp; the
14
+ rename onto the target stays atomic, so readers still never see a torn write.
15
+ - **`graph` accepts `--kb` on the parent command** (`graph --kb ./kb hubs`), like every
16
+ other command — it previously errored "unknown option --kb" unless `--kb` came after the
17
+ subcommand.
18
+ - **`ask` rejects an empty/whitespace question** instead of running a no-op search.
19
+ - **`ask --retrieve-only` tells an empty wiki apart from a no-match query** — it no longer
20
+ tells a user with a populated KB to go build pages they already have.
21
+ - **`connect` reports a clear error for a missing/non-directory project path** instead of
22
+ leaking a raw ENOENT for an internal file.
23
+ - **`scan` no longer reports a phantom "+1 to convert" for an exact-duplicate file** — the
24
+ incremental count now agrees with the compile plan (duplicates are reported separately).
25
+ - **Clearer frontmatter error**: a page that opens `---` but never closes it is reported as
26
+ `unterminated-frontmatter`, not `missing-frontmatter`.
27
+ - **Clearer `embed` error** when no embedding model is configured (names the field to set,
28
+ for both remote and local).
29
+
30
+ **Added (lint):**
31
+
32
+ - `type-dir-mismatch` — flags a page whose `type` disagrees with its directory (e.g.
33
+ `type: source` in `concepts/`), which the indexer would otherwise silently miscategorize.
34
+ - `self-supersede` — flags a page whose `superseded_by` points at itself.
35
+
36
+ **Changed (internal):**
37
+
38
+ - `loadEmbedConfig` reads the global config once per call and resolves a provider-level
39
+ embedding model in priority order, consistent with chat provider selection.
40
+ - Local embedding no longer builds a network transport (no undici import / proxy agent),
41
+ since it makes no network calls.
42
+
43
+ ## 0.9.1 (2026-07-12)
44
+
45
+ Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
46
+ vectors produced. Suite 271 → 272.
47
+
48
+ **Fixed:**
49
+
50
+ - **Long pages no longer truncate silently on local models.** A local model
51
+ (e.g. `local:Xenova/multilingual-e5-small`) has a ~512-token input window and its
52
+ pipeline silently truncates longer pages to a head-only vector. `embed` now warns per
53
+ page and reports a `truncated N` count in its summary, so the head-only truncation is
54
+ visible instead of silent. The vector produced is unchanged.
55
+ - **A broken transitive dependency is no longer masked as "install
56
+ @huggingface/transformers".** The missing-dependency hint now fires only when
57
+ transformers.js itself is absent; when transformers.js is installed but one of its
58
+ (native) deps fails to load, the original error naming the real culprit passes through.
59
+ - **Clearer `embed` error when no embedding model is configured** — it now names the
60
+ actual fix (set `embeddingModel`) for both remote and local, instead of telling a user
61
+ who already has chat credentials to add credentials.
62
+
3
63
  ## 0.9.0 (2026-07-12)
4
64
 
5
65
  **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>')
@@ -88,10 +89,16 @@ program.command('ask <question>')
88
89
  .action(async (question, opts) => {
89
90
  const k = Number.parseInt(opts.k, 10)
90
91
  if (!Number.isFinite(k) || k < 1) { console.error(`invalid -k value: ${opts.k} (expected a positive integer)`); process.exit(1) }
92
+ if (!question.trim()) { console.error('empty question — provide something to ask'); process.exit(1) }
91
93
  const r = await askKb(opts.kb, question, { k, retrieveOnly: opts.retrieveOnly })
92
94
  if (opts.retrieveOnly) {
93
95
  if (r.pages.length === 0) {
94
- console.error('no pages located the KB has no matching wiki pages (build them with the wiki-build skill, or check that wiki/ contains pages)')
96
+ // Distinguish "wiki is empty" (build pages) from "wiki has pages but none
97
+ // matched" (rephrase) — the old fixed message told users with a full KB to go
98
+ // build pages they already have.
99
+ console.error(r.kbEmpty
100
+ ? 'no pages located — the wiki has no pages yet (build them with the wiki-build skill, or check that wiki/ contains pages)'
101
+ : 'no pages located — no wiki page matched your query (the wiki has pages; try different or fewer search terms)')
95
102
  return
96
103
  }
97
104
  for (const h of r.pages) console.log(`${h.score.toFixed(3)} ${h.relPath} [${(h.sources ?? ['bm25']).join('+')}]`)
@@ -163,12 +170,17 @@ program.command('export')
163
170
  })
164
171
 
165
172
  const graphCmd = program.command('graph').description('query wiki/graph.json: path | neighbors | hubs (zero-LLM traversal)')
173
+ // Accept --kb on the parent too, so `graph --kb ./kb hubs` works like every other
174
+ // command (`ask --kb`, `lint --kb`). Subcommands keep their own --kb (no default) and
175
+ // fall back to the parent's, so both placements resolve and neither silently wins '.'.
176
+ .option('--kb <dir>', 'knowledge base root', '.')
177
+ const graphKb = (opts) => opts.kb ?? graphCmd.opts().kb
166
178
 
167
179
  graphCmd.command('path <from> <to>')
168
180
  .description('shortest link chain between two page ids (either link direction)')
169
- .option('--kb <dir>', 'knowledge base root', '.')
181
+ .option('--kb <dir>', 'knowledge base root')
170
182
  .action((from, to, opts) => {
171
- const r = shortestPath(loadGraph(opts.kb), from, to)
183
+ const r = shortestPath(loadGraph(graphKb(opts)), from, to)
172
184
  if (!r) { console.log(`no path between ${from} and ${to}`); return }
173
185
  console.log(r.nodes[0])
174
186
  for (const h of r.hops) console.log(` ${h.dir === 'out' ? `-[${h.type}]->` : `<-[${h.type}]-`} ${h.to}${h.confidence ? ` (${h.confidence})` : ''}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
@@ -177,11 +189,11 @@ graphCmd.command('path <from> <to>')
177
189
  graphCmd.command('neighbors <id>')
178
190
  .description('pages within N hops (links counted in both directions)')
179
191
  .option('-d, --depth <n>', 'expansion depth', '1')
180
- .option('--kb <dir>', 'knowledge base root', '.')
192
+ .option('--kb <dir>', 'knowledge base root')
181
193
  .action((id, opts) => {
182
194
  const depth = Number.parseInt(opts.depth, 10)
183
195
  if (!Number.isFinite(depth) || depth < 1) { console.error(`invalid depth: ${opts.depth} (expected a positive integer)`); process.exit(1) }
184
- const r = neighborhood(loadGraph(opts.kb), id, depth)
196
+ const r = neighborhood(loadGraph(graphKb(opts)), id, depth)
185
197
  if (!r.length) { console.log(`${id} has no linked neighbors`); return }
186
198
  for (const n of r) console.log(`d=${n.distance} ${n.id} [${n.type}${n.confidence ? '/' + n.confidence : ''} ${n.dir}]${n.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
187
199
  })
@@ -189,11 +201,11 @@ graphCmd.command('neighbors <id>')
189
201
  graphCmd.command('hubs')
190
202
  .description('most-connected pages (degree ranking; raw files excluded)')
191
203
  .option('--top <n>', 'how many to show', '10')
192
- .option('--kb <dir>', 'knowledge base root', '.')
204
+ .option('--kb <dir>', 'knowledge base root')
193
205
  .action((opts) => {
194
206
  const top = Number.parseInt(opts.top, 10)
195
207
  if (!Number.isFinite(top) || top < 1) { console.error(`invalid --top: ${opts.top} (expected a positive integer)`); process.exit(1) }
196
- for (const h of hubs(loadGraph(opts.kb), { top })) {
208
+ for (const h of hubs(loadGraph(graphKb(opts)), { top })) {
197
209
  console.log(`${String(h.degree).padStart(3)} ${h.id} (in ${h.in} / out ${h.out}) ${h.title}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
198
210
  }
199
211
  })
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.2",
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/ask.mjs CHANGED
@@ -8,6 +8,7 @@ import { worstCaseTokens } from './scanner.mjs'
8
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
+ import { isLocalModel } from './local-embed.mjs'
11
12
  import { fetchWithRetry } from './retry.mjs'
12
13
 
13
14
  // Two per-KB caches, both keyed by a cheap page-set freshness token so neither can
@@ -149,7 +150,11 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
149
150
  const injected = fetchImpl || pipelineFactory
150
151
  const t = injected
151
152
  ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
152
- : { ...(await makeTransport()), retry }
153
+ // A local query embedding makes no network call (embedLocal ignores the
154
+ // transport), so skip makeTransport — no undici import / proxy agent for nothing.
155
+ : isLocalModel(cfg.embeddingModel)
156
+ ? { retry }
157
+ : { ...(await makeTransport()), retry }
153
158
  const [qv] = await embedTexts(cfg, t, [question], { role: 'query' })
154
159
  const qn = normalize(qv)
155
160
  const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
@@ -232,7 +237,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
232
237
  export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
233
238
  const p = kbPaths(kbRoot)
234
239
  let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
235
- if (retrieveOnly) return { pages: hits, answer: null }
240
+ // kbEmpty lets the CLI tell "no pages built yet" apart from "pages exist but none
241
+ // matched". Only pay the livePages listing when there were zero hits to explain.
242
+ if (retrieveOnly) return { pages: hits, answer: null, kbEmpty: hits.length === 0 && livePages(kbRoot).length === 0 }
236
243
  let validIds
237
244
  if (hits.length === 0) {
238
245
  validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
package/src/connect.mjs CHANGED
@@ -15,6 +15,10 @@ function renderBlock(kbs) {
15
15
  }
16
16
 
17
17
  export function connectProject(projectDir, { kb, role = 'project', remove = false }) {
18
+ // Fail with a clear message instead of leaking a raw ENOENT for an internal file
19
+ // (.llm-wiki.json) when the user points connect at a missing/typo'd project path.
20
+ if (!fs.existsSync(projectDir)) throw new Error(`project directory not found: ${projectDir}`)
21
+ if (!fs.statSync(projectDir).isDirectory()) throw new Error(`not a directory: ${projectDir}`)
18
22
  const regFile = path.join(projectDir, '.llm-wiki.json')
19
23
  const regExists = fs.existsSync(regFile)
20
24
  const registry = regExists ? readJsonFile(regFile) : { kbs: [] }
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
@@ -98,7 +120,11 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
98
120
  const injected = fetchImpl || pipelineFactory
99
121
  const t = injected
100
122
  ? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
101
- : { ...(await makeTransport()), retry }
123
+ // A local model makes no network call (embedLocal ignores the transport), so
124
+ // skip makeTransport — it would import undici / build a proxy agent for nothing.
125
+ : local
126
+ ? { retry }
127
+ : { ...(await makeTransport()), retry }
102
128
  for (let i = 0; i < jobs.length; i += BATCH) {
103
129
  const batch = jobs.slice(i, i + BATCH)
104
130
  const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
@@ -119,5 +145,5 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
119
145
  const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
120
146
  const store = { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages }
121
147
  saveVectorStore(kbRoot, store)
122
- return { embedded, reused, pruned, model: cfg.embeddingModel, dim: store.dim }
148
+ return { embedded, reused, pruned, truncated: truncatedCount, model: cfg.embeddingModel, dim: store.dim }
123
149
  }
@@ -1,10 +1,17 @@
1
1
  import YAML from 'yaml'
2
2
 
3
3
  const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
4
+ const FM_OPEN_RE = /^---\r?\n/
4
5
 
5
6
  export function parseFrontmatter(text) {
6
7
  const m = text.match(FM_RE)
7
- if (!m) return null
8
+ if (!m) {
9
+ // Opens with a `---` delimiter but never closes it — an unterminated block, not
10
+ // an absent one. A forgotten closing `---` is a common authoring slip; report it
11
+ // distinctly so the fix is "close the frontmatter", not "add frontmatter".
12
+ if (FM_OPEN_RE.test(text)) return { error: 'unterminated-frontmatter' }
13
+ return null
14
+ }
8
15
  let data
9
16
  try { data = YAML.parse(m[1]) } catch { return { error: 'invalid-yaml' } }
10
17
  if (data === null || typeof data !== 'object') return { error: 'invalid-yaml' }
package/src/json.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  import fs from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ // Per-write temp counter: combined with the pid it keeps concurrent writers to the
5
+ // same target on distinct temp files even within one process (see writeFileAtomic).
6
+ let atomicSeq = 0
2
7
 
3
8
  // JSON.parse with the offending file named in the error — a bare SyntaxError
4
9
  // from a corrupt state file gives the user nothing to act on.
@@ -22,7 +27,19 @@ export function readJsonFile(file, { redactContents = false } = {}) {
22
27
  // only ever sees the whole old file or the whole new one. Single source of the pattern
23
28
  // that manifest/vector already used and buildIndex's derived stores previously missed.
24
29
  export function writeFileAtomic(file, data) {
25
- const tmp = `${file}.tmp`
26
- fs.writeFileSync(tmp, data)
27
- fs.renameSync(tmp, file)
30
+ // Unique temp name per write. A fixed `${file}.tmp` makes two concurrent writers to
31
+ // the same target share one temp file: the first rename consumes it, the second then
32
+ // renames a now-missing file and crashes with ENOENT (concurrent `index`, or MCP +
33
+ // CLI touching the same derived file). pid + seq + random keeps temps independent.
34
+ // The rename onto `file` stays atomic, so readers still never see a torn write.
35
+ const tmp = `${file}.${process.pid}.${(atomicSeq++).toString(36)}.${randomBytes(4).toString('hex')}.tmp`
36
+ try {
37
+ fs.writeFileSync(tmp, data)
38
+ fs.renameSync(tmp, file)
39
+ } catch (err) {
40
+ // Unique temps don't self-overwrite on the next run, so a failed write must clean
41
+ // up after itself or it leaks a stray .tmp beside the target.
42
+ try { fs.unlinkSync(tmp) } catch { /* best-effort */ }
43
+ throw err
44
+ }
28
45
  }
package/src/lint.mjs CHANGED
@@ -7,6 +7,13 @@ import { extractWikilinks, buildIndex } from './indexer.mjs'
7
7
  import { loadManifest } from './manifest.mjs'
8
8
  import { writeFileAtomic } from './json.mjs'
9
9
 
10
+ // The AGENTS.md contract binds each page type to its directory (source↔sources/, …).
11
+ // A page whose `type` disagrees with its directory is silently miscategorized: the
12
+ // indexer groups index.md sections by `type`, but ids / wikilinks / graph nodes use
13
+ // the directory — so e.g. a `type: source` page in concepts/ is filed under "Sources"
14
+ // yet linked as `concepts/…`.
15
+ const DIR_TYPE = { sources: 'source', entities: 'entity', concepts: 'concept', comparisons: 'comparison' }
16
+
10
17
  export async function lintKb(kbRoot, { fix = false } = {}) {
11
18
  const p = kbPaths(kbRoot)
12
19
  const cfg = loadKbConfig(kbRoot)
@@ -20,6 +27,12 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
20
27
  for (const pg of pages) {
21
28
  if (pg.error) { mechanical.push({ rule: 'invalid-frontmatter', path: pg.relPath, detail: pg.error }); continue }
22
29
  for (const issue of validatePage(pg)) mechanical.push({ rule: 'missing-field', path: pg.relPath, detail: issue })
30
+ // type must match the directory (AGENTS.md contract) — a mismatch is miscategorized
31
+ // by the indexer. Skip when type is absent (already flagged as a missing field).
32
+ const dir = pg.relPath.split('/')[0]
33
+ if (DIR_TYPE[dir] && pg.data.type !== undefined && pg.data.type !== DIR_TYPE[dir]) {
34
+ mechanical.push({ rule: 'type-dir-mismatch', path: pg.relPath, detail: `type "${pg.data.type}" in ${dir}/ (expected "${DIR_TYPE[dir]}")` })
35
+ }
23
36
  for (const target of extractWikilinks(pg.body)) {
24
37
  if (target.startsWith('raw/')) {
25
38
  if (!fs.existsSync(path.join(kbRoot, target)) && !fs.existsSync(path.join(kbRoot, target + '.md'))) mechanical.push({ rule: 'broken-raw-link', path: pg.relPath, detail: `-> ${target}` })
@@ -34,8 +47,15 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
34
47
  if (pg.data.status !== undefined && !PAGE_STATUSES.includes(pg.data.status)) {
35
48
  mechanical.push({ rule: 'invalid-status', path: pg.relPath, detail: `status "${pg.data.status}" (expected ${PAGE_STATUSES.join(' | ')})` })
36
49
  }
37
- if (pg.data.superseded_by !== undefined && !ids.has(String(pg.data.superseded_by))) {
38
- mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
50
+ if (pg.data.superseded_by !== undefined) {
51
+ const supTarget = String(pg.data.superseded_by)
52
+ // A page cannot be its own replacement: superseded_by == self invalidates the page
53
+ // with no valid successor (checked before target-missing, since the self id exists).
54
+ if (supTarget === pg.relPath.replace(/\.md$/, '')) {
55
+ mechanical.push({ rule: 'self-supersede', path: pg.relPath, detail: 'superseded_by points at itself' })
56
+ } else if (!ids.has(supTarget)) {
57
+ mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
58
+ }
39
59
  }
40
60
  if (pg.data.relations !== undefined && !Array.isArray(pg.data.relations)) {
41
61
  mechanical.push({ rule: 'invalid-relation-entry', path: pg.relPath, detail: 'relations must be a YAML list' })
@@ -23,12 +23,36 @@ function resolveProviders(fileCfg) {
23
23
  return null
24
24
  }
25
25
 
26
- export function loadLlmConfig(kbRoot) {
26
+ // The embeddingModel a credential-less (local) config would use: the flat top-level
27
+ // form wins, else the first provider IN PRIORITY ORDER that declares one — the same
28
+ // ordering resolveProviders uses, so the chat and embed paths can't disagree on which
29
+ // provider wins. Only file-configured providers are scanned (BUILTIN declares none).
30
+ function resolveEmbeddingModel(fileCfg) {
31
+ if (typeof fileCfg.embeddingModel === 'string') return fileCfg.embeddingModel
32
+ const { priority, providers } = fileCfg
33
+ if (!providers) return null
34
+ for (const name of priority ?? Object.keys(providers)) {
35
+ const em = providers[name]?.embeddingModel
36
+ if (em) return em
37
+ }
38
+ return null
39
+ }
40
+
41
+ // Read ~/.llm-wiki/config.json once per entry point. redactContents: this file holds
42
+ // API keys — a corrupt-JSON error must not echo a fragment of it to the terminal.
43
+ function readGlobalConfig() {
27
44
  const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
28
45
  const globalFile = path.join(dir, 'config.json')
29
- // redactContents: this file holds API keys a corrupt-JSON error must not
30
- // echo a fragment of it to the terminal.
31
- const fileCfg = fs.existsSync(globalFile) ? readJsonFile(globalFile, { redactContents: true }) : {}
46
+ return fs.existsSync(globalFile) ? readJsonFile(globalFile, { redactContents: true }) : {}
47
+ }
48
+
49
+ export function loadLlmConfig(kbRoot) {
50
+ return loadChatConfig(kbRoot, readGlobalConfig())
51
+ }
52
+
53
+ // Resolve a chat-complete config from an already-read global fileCfg (+ the KB's
54
+ // optional llm override). Returns null unless baseURL/apiKey/model are all present.
55
+ function loadChatConfig(kbRoot, fileCfg) {
32
56
  // flat form: explicit custom endpoint wins outright
33
57
  let cfg = (fileCfg.baseURL && fileCfg.apiKey && fileCfg.model)
34
58
  ? { baseURL: fileCfg.baseURL, apiKey: fileCfg.apiKey, model: fileCfg.model, ...(fileCfg.embeddingModel ? { embeddingModel: fileCfg.embeddingModel } : {}) }
@@ -72,14 +96,10 @@ export function loadLlmConfig(kbRoot) {
72
96
  // call the API, so it resolves only when loadLlmConfig does. loadLlmConfig's own
73
97
  // (chat-complete-or-null) contract is unchanged.
74
98
  export function loadEmbedConfig(kbRoot) {
75
- const chat = loadLlmConfig(kbRoot)
99
+ const fileCfg = readGlobalConfig()
100
+ const chat = loadChatConfig(kbRoot, fileCfg)
76
101
  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)
102
+ const em = resolveEmbeddingModel(fileCfg)
83
103
  return (typeof em === 'string' && em.startsWith('local:')) ? { embeddingModel: em } : null
84
104
  }
85
105
 
@@ -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
package/src/scanner.mjs CHANGED
@@ -150,6 +150,12 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
150
150
  const diff = diffManifest(loadManifest(kbRoot), files.map(f => ({ rel: f.rel, hash: f.hash })))
151
151
  const toCompileSet = new Set([...diff.added, ...diff.changed].map(e => e.rel))
152
152
  const toCompile = files.filter(f => toCompileSet.has(f.rel) && !exactDups.has(f.rel))
153
+ // Exact duplicates are excluded from the compile batches (toCompile), so they must not
154
+ // inflate the reported added/changed counts either — a deduped file is never written to
155
+ // the manifest, so it would otherwise read as a permanent "+1 to convert" that `convert`
156
+ // never clears and that contradicts the "0 batches" plan. Dups stay in `duplicates.exact`.
157
+ const incAdded = diff.added.filter(e => !exactDups.has(e.rel)).length
158
+ const incChanged = diff.changed.filter(e => !exactDups.has(e.rel)).length
153
159
  const batches = []
154
160
  for (let i = 0; i < toCompile.length; i += cfg.batchSize) {
155
161
  batches.push(toCompile.slice(i, i + cfg.batchSize).map(f => f.rel))
@@ -165,7 +171,7 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
165
171
  files: files.map(({ _sig, ...f }) => f),
166
172
  skipped,
167
173
  duplicates: { exact, near },
168
- incremental: { added: diff.added.length, changed: diff.changed.length, removed: diff.removed.length, unchanged: diff.unchanged.length },
174
+ incremental: { added: incAdded, changed: incChanged, removed: diff.removed.length, unchanged: diff.unchanged.length },
169
175
  domainMixture: detectDomainMixture(files, kbRoot),
170
176
  batches,
171
177
  estimate,