@sdsrs/llm-wiki 0.8.1 → 0.8.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 (2026-07-12)
4
+
5
+ Performance, no behavior change. Suite 260 → 261.
6
+
7
+ **Changed:**
8
+
9
+ - **Retrieval no longer rebuilds the BM25 index from disk on every query.** The
10
+ index is cached per KB and reused while the wiki pages and `bm25TitleWeight` are
11
+ unchanged; a cheap freshness token (each live page's mtime + size) rebuilds it on
12
+ any page add / edit / removal / invalidation or config change, so results are
13
+ never stale. A one-shot CLI call is unaffected (it built the index once anyway);
14
+ a long-lived MCP server drops per-`wiki_search` cost (~9× on the dogfood KB:
15
+ 9.4 ms → 1.0 ms/query). Cache is bounded (last 8 KBs).
16
+
3
17
  ## 0.8.1 (2026-07-12)
4
18
 
5
19
  Audit-driven remediation batch (see the v0.8.0 audit report): test-gate
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.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
@@ -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,38 @@ 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
20
+
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) {
29
+ const wiki = kbPaths(kbRoot).wiki
30
+ const parts = [`w=${w}`]
31
+ for (const dir of PAGE_DIRS) {
32
+ let entries
33
+ try { entries = fs.readdirSync(path.join(wiki, dir)) } catch { continue }
34
+ for (const f of entries.sort()) {
35
+ if (!f.endsWith('.md')) continue
36
+ try {
37
+ const st = fs.statSync(path.join(wiki, dir, f))
38
+ parts.push(`${dir}/${f}:${st.mtimeMs}:${st.size}`)
39
+ } catch { /* vanished mid-scan: next call rebuilds */ }
40
+ }
41
+ }
42
+ return parts.join('|')
43
+ }
44
+
13
45
  export function retrievePages(kbRoot, question, k = 6) {
14
46
  // Title is a field: repeat it bm25TitleWeight times in the indexed text so a
15
47
  // title term outweighs the same term buried in the body (measured +0.04 Recall@5
@@ -18,12 +50,20 @@ export function retrievePages(kbRoot, question, k = 6) {
18
50
  // loadKbConfig guarantees a finite integer >= 1; cap the upper bound so a large
19
51
  // value can't materialize a giant per-page array (Array(w).fill) and OOM retrieval.
20
52
  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 }))
53
+ const token = pageSetToken(kbRoot, w)
54
+ 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 => ({
58
+ id: p.relPath,
59
+ text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
60
+ })))
61
+ 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)
64
+ entry = bm25Cache.get(kbRoot)
65
+ }
66
+ return searchBm25(entry.idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
27
67
  }
28
68
 
29
69
  const RRF_K = 60
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']