@sdsrs/llm-wiki 0.8.1 → 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,45 @@
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
+
29
+ ## 0.8.2 (2026-07-12)
30
+
31
+ Performance, no behavior change. Suite 260 → 261.
32
+
33
+ **Changed:**
34
+
35
+ - **Retrieval no longer rebuilds the BM25 index from disk on every query.** The
36
+ index is cached per KB and reused while the wiki pages and `bm25TitleWeight` are
37
+ unchanged; a cheap freshness token (each live page's mtime + size) rebuilds it on
38
+ any page add / edit / removal / invalidation or config change, so results are
39
+ never stale. A one-shot CLI call is unaffected (it built the index once anyway);
40
+ a long-lived MCP server drops per-`wiki_search` cost (~9× on the dogfood KB:
41
+ 9.4 ms → 1.0 ms/query). Cache is bounded (last 8 KBs).
42
+
3
43
  ## 0.8.1 (2026-07-12)
4
44
 
5
45
  Audit-driven remediation batch (see the v0.8.0 audit report): test-gate
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.1",
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
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { kbPaths } from './paths.mjs'
4
4
  import { loadKbConfig } from './templates.mjs'
5
- import { listWikiPages, isInvalidated, asList } from './pages.mjs'
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
8
  import { loadLlmConfig, makeTransport } from './llm-config.mjs'
@@ -10,6 +10,52 @@ import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
10
10
  import { embedTexts } from './embed.mjs'
11
11
  import { fetchWithRetry } from './retry.mjs'
12
12
 
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
24
+
25
+ function pagesToken(kbRoot) {
26
+ const wiki = kbPaths(kbRoot).wiki
27
+ const parts = []
28
+ for (const dir of PAGE_DIRS) {
29
+ let entries
30
+ try { entries = fs.readdirSync(path.join(wiki, dir)) } catch { continue }
31
+ for (const f of entries.sort()) {
32
+ if (!f.endsWith('.md')) continue
33
+ try {
34
+ const st = fs.statSync(path.join(wiki, dir, f))
35
+ parts.push(`${dir}/${f}:${st.mtimeMs}:${st.size}`)
36
+ } catch { /* vanished mid-scan: next call rebuilds */ }
37
+ }
38
+ }
39
+ return parts.join('|')
40
+ }
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
+
13
59
  export function retrievePages(kbRoot, question, k = 6) {
14
60
  // Title is a field: repeat it bm25TitleWeight times in the indexed text so a
15
61
  // title term outweighs the same term buried in the body (measured +0.04 Recall@5
@@ -18,12 +64,20 @@ export function retrievePages(kbRoot, question, k = 6) {
18
64
  // loadKbConfig guarantees a finite integer >= 1; cap the upper bound so a large
19
65
  // value can't materialize a giant per-page array (Array(w).fill) and OOM retrieval.
20
66
  const w = Math.min(25, loadKbConfig(kbRoot).bm25TitleWeight)
21
- const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
22
- const idx = buildBm25Index(pages.map(p => ({
23
- id: p.relPath,
24
- text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
25
- })))
26
- return searchBm25(idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
67
+ const token = pagesToken(kbRoot)
68
+ const bmKey = `${token}|w=${w}` // title weight changes the indexed text but not the page list
69
+ let entry = bm25Cache.get(kbRoot)
70
+ if (!entry || entry.token !== bmKey) {
71
+ const idx = buildBm25Index(livePages(kbRoot, token).map(p => ({
72
+ id: p.relPath,
73
+ text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
74
+ })))
75
+ bm25Cache.delete(kbRoot) // re-insert so this KB becomes most-recent for LRU eviction
76
+ bm25Cache.set(kbRoot, { token: bmKey, idx })
77
+ while (bm25Cache.size > CACHE_MAX) bm25Cache.delete(bm25Cache.keys().next().value)
78
+ entry = bm25Cache.get(kbRoot)
79
+ }
80
+ return searchBm25(entry.idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
27
81
  }
28
82
 
29
83
  const RRF_K = 60
@@ -81,7 +135,9 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
81
135
  if (retrieval === 'hybrid') throw new Error(`retrieval 'hybrid' unavailable: ${what}`)
82
136
  return asBm25()
83
137
  }
84
- 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()
85
141
  const store = loadVectorStore(kbRoot)
86
142
  if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
87
143
  const cfg = loadLlmConfig(kbRoot)
@@ -98,10 +154,10 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
98
154
  // renamed since then still have vectors. Keep only hits that map to a live,
99
155
  // non-invalidated page so retired knowledge cannot resurface (and askKb never
100
156
  // ENOENTs reading a vanished file).
101
- 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))
102
158
  const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
103
159
  const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
104
- { lexicalGuard: retrieval === 'auto' && loadKbConfig(kbRoot).lexicalGuard })
160
+ { lexicalGuard: retrieval === 'auto' && kbCfg.lexicalGuard })
105
161
  return { hits, usedVector: true, guardApplied }
106
162
  } catch (err) {
107
163
  if (retrieval === 'hybrid') throw err
@@ -176,9 +232,7 @@ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fet
176
232
  if (retrieveOnly) return { pages: hits, answer: null }
177
233
  let validIds
178
234
  if (hits.length === 0) {
179
- validIds = new Set(listWikiPages(kbRoot)
180
- .filter(pg => !pg.error && !isInvalidated(pg))
181
- .map(pg => pg.relPath.replace(/\.md$/, '')))
235
+ validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
182
236
  if (validIds.size === 0) throw new Error('No relevant pages found — the knowledge base has no valid pages.')
183
237
  }
184
238
  const cfg = loadLlmConfig(kbRoot)
package/src/pages.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { kbPaths } from './paths.mjs'
4
4
  import { parseFrontmatter } from './frontmatter.mjs'
5
5
 
6
- const PAGE_DIRS = ['sources', 'entities', 'concepts', 'comparisons']
6
+ export const PAGE_DIRS = ['sources', 'entities', 'concepts', 'comparisons']
7
7
  const REQUIRED = ['type', 'title', 'description', 'tags', 'created', 'updated']
8
8
 
9
9
  export const PAGE_STATUSES = ['active', 'invalidated']