@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 +14 -0
- package/package.json +1 -1
- package/src/ask.mjs +47 -7
- package/src/pages.mjs +1 -1
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
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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']
|