@sdsrs/llm-wiki 0.8.2 → 0.8.3

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3 (2026-07-12)
4
+
5
+ Performance and test-hardening cleanup; no shipped behavior change. Suite 261 → 263.
6
+
7
+ **Changed:**
8
+
9
+ - **Retrieval parses the wiki page set once per query, not up to three times.** The
10
+ BM25 build, the vector-hit valid set, and the index-listing fallback's id set now
11
+ share one page cache keyed by the same mtime + size freshness token as the BM25
12
+ index (added in 0.8.2), so any add / edit / removal / invalidation still busts it
13
+ and retired knowledge can never resurface. On a long-lived MCP server a vector
14
+ (`auto`/`hybrid`) query drops its second full page parse; `locatePages` also reads
15
+ `wiki.config.json` once instead of twice.
16
+ - **The `mcp` command loads the MCP SDK + zod lazily.** They were imported at CLI
17
+ startup for every command; only `mcp` needs them. `scan` / `convert` / `ask` /
18
+ `init` and friends now skip ~200–250 ms of import cost.
19
+
20
+ **Internal (not shipped in the npm package):**
21
+
22
+ - Atomic-write regression guards: `saveManifest`, `saveVectorStore`, and the `scan`
23
+ plan write now assert the temp + rename path via a `fs.renameSync` spy — the old
24
+ `.tmp`-absence check alone also passed for a direct truncating write (mem #10097).
25
+ - The full eval report (`scripts/eval/run-all.mjs`) judges the shipped default
26
+ retrieval mode: answer-level head-to-head now defaults to `bm25,auto` (was the
27
+ hardcoded `bm25,hybrid`) and takes an `--answer-arms` flag.
28
+
3
29
  ## 0.8.2 (2026-07-12)
4
30
 
5
31
  Performance, no behavior change. Suite 260 → 261.
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.8.3",
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
@@ -10,24 +10,21 @@ 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 }))
@@ -121,7 +135,9 @@ 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
143
  const cfg = loadLlmConfig(kbRoot)
@@ -138,10 +154,10 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
138
154
  // renamed since then still have vectors. Keep only hits that map to a live,
139
155
  // non-invalidated page so retired knowledge cannot resurface (and askKb never
140
156
  // ENOENTs reading a vanished file).
141
- const valid = new Set(listWikiPages(kbRoot).filter(pg => !pg.error && !isInvalidated(pg)).map(pg => pg.relPath))
157
+ const valid = new Set(livePages(kbRoot).map(pg => pg.relPath))
142
158
  const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
143
159
  const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
144
- { lexicalGuard: retrieval === 'auto' && loadKbConfig(kbRoot).lexicalGuard })
160
+ { lexicalGuard: retrieval === 'auto' && kbCfg.lexicalGuard })
145
161
  return { hits, usedVector: true, guardApplied }
146
162
  } catch (err) {
147
163
  if (retrieval === 'hybrid') throw err
@@ -216,9 +232,7 @@ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fet
216
232
  if (retrieveOnly) return { pages: hits, answer: null }
217
233
  let validIds
218
234
  if (hits.length === 0) {
219
- validIds = new Set(listWikiPages(kbRoot)
220
- .filter(pg => !pg.error && !isInvalidated(pg))
221
- .map(pg => pg.relPath.replace(/\.md$/, '')))
235
+ validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
222
236
  if (validIds.size === 0) throw new Error('No relevant pages found — the knowledge base has no valid pages.')
223
237
  }
224
238
  const cfg = loadLlmConfig(kbRoot)