@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 +40 -0
- package/bin/llm-wiki.mjs +3 -1
- package/package.json +1 -1
- package/src/ask.mjs +67 -13
- package/src/pages.mjs +1 -1
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
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
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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(
|
|
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' &&
|
|
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(
|
|
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']
|