@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 +26 -0
- package/bin/llm-wiki.mjs +3 -1
- package/package.json +1 -1
- package/src/ask.mjs +42 -28
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
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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 = [
|
|
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 =
|
|
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 !==
|
|
56
|
-
const
|
|
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 >
|
|
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
|
-
|
|
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(
|
|
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' &&
|
|
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(
|
|
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)
|