@sdsrs/llm-wiki 0.7.3 → 0.8.0

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-07-12)
4
+
5
+ Retrieval `auto` mode gains a cross-language noise guard — an additive config
6
+ key (`lexicalGuard`, default on) with an opt-out. No KB migration, no API
7
+ change, no new dependency. Suite 234 → 246.
8
+
9
+ **Changed:**
10
+
11
+ - **`auto` retrieval ranks vector-only when the lexical and semantic channels
12
+ disagree completely.** When BM25's top-k and the vector top-k return a
13
+ *disjoint* set of pages, BM25 contributed nothing the semantic channel
14
+ corroborates — on a cross-language query (e.g. a Chinese question against an
15
+ English KB) its incidental-token matches are noise that rank-based RRF would
16
+ blend in, pushing correct pages out of top-k. `auto` now drops BM25 and ranks
17
+ by the vector channel alone in that case. Measured on a 500-page KB: pure
18
+ cross-language Recall@5 rose 0.538 → 0.769 (matching pure vector) while
19
+ same-language fact retrieval stayed at 1.000 — the guard never fired on a
20
+ fact probe, so lexical retrieval is untouched where it is doing the work. The
21
+ guard is `auto`-only; explicit `hybrid` mode keeps pure RRF fusion. Set
22
+ `"lexicalGuard": false` in `wiki.config.json` to restore the previous
23
+ always-fuse behavior.
24
+
25
+ **Added:**
26
+
27
+ - `lexicalGuard` config key (boolean, default `true`), emitted into new KBs'
28
+ `wiki.config.json` by `init`.
29
+
30
+ ## 0.7.4 (2026-07-12)
31
+
32
+ Two robustness fixes surfaced by an adversarial QA pass over the CLI. No config
33
+ schema change, no KB migration, no retrieval-default change. Suite 230 → 234.
34
+
35
+ **Fixes:**
36
+
37
+ - **`index` warns when a page is skipped for invalid frontmatter.** `buildIndex`
38
+ silently excluded any page with unparseable YAML frontmatter from
39
+ `index.md`/`graph.json`/`llms.txt`. Because the docs route hand-editors to
40
+ `index` (not `lint`) after editing, a broken-YAML edit made the page vanish
41
+ from every query surface with no signal — `indexed N pages`, exit 0, nothing
42
+ else. `buildIndex` now returns the skipped pages and the CLI prints a one-line
43
+ stderr warning naming them and pointing at `lint`; stdout stays the clean
44
+ count, and `lint` still owns the detailed per-page report.
45
+ - **`scan` near-duplicate detection uses 128 minhash permutations (was 32).**
46
+ Near-dup similarity was estimated from a 32-permutation minhash and compared
47
+ against the 0.85 threshold, but at 32 perms the estimate's standard error
48
+ (~0.05) let a genuine near-duplicate (true Jaccard 0.898) estimate 0.844 and
49
+ be reported as `near: 0`. 128 perms tightens the estimate (SD ~0.026) so real
50
+ near-dups clear the threshold; the cost is trivial next to the per-file
51
+ sha256+read scan already does, and the signature is stripped before the plan
52
+ is persisted (no on-disk change).
53
+
3
54
  ## 0.7.3 (2026-07-12)
4
55
 
5
56
  Two robustness fixes from the final targeted dogfooding sweep. No config schema
package/bin/llm-wiki.mjs CHANGED
@@ -65,6 +65,12 @@ program.command('index')
65
65
  .action((opts) => {
66
66
  const r = buildIndex(opts.kb)
67
67
  console.log(`indexed ${r.pageCount} pages${r.topicsSplit ? ' (split into topics/)' : ''}`)
68
+ // A page with unparseable frontmatter is excluded from index.md/graph.json/llms.txt. Surface
69
+ // it on stderr (stdout stays the clean count) so a hand-edit that broke the YAML doesn't
70
+ // silently vanish — the README points hand-editors here, not at `lint`.
71
+ if (r.skipped?.length) {
72
+ console.error(`warning: skipped ${r.skipped.length} page(s) with invalid frontmatter (run 'lint' for details): ${r.skipped.map(s => s.relPath).join(', ')}`)
73
+ }
68
74
  })
69
75
 
70
76
  program.command('embed')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
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
@@ -41,16 +41,39 @@ export function rrfFuse(lists, k) {
41
41
  return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, k)
42
42
  }
43
43
 
44
+ // auto-mode guard: when the lexical (BM25) and semantic (vector) channels return
45
+ // disjoint top-k page sets, BM25 contributed nothing the vector channel corroborates
46
+ // — on a cross-language query its incidental-token hits are noise that rank-based RRF
47
+ // would blend in and push correct pages out of top-k. In that case drop BM25 and rank
48
+ // vector-only. Parameter-free: the overlap test uses the same k the call retrieves.
49
+ // vector.length > 0 is required so the guard never returns an empty list.
50
+ export function fuseChannels({ bm25, vector }, k, { lexicalGuard = true } = {}) {
51
+ if (lexicalGuard && vector.length > 0) {
52
+ const bm25Ids = new Set(bm25.map(h => h.relPath))
53
+ const disjoint = !vector.some(h => bm25Ids.has(h.relPath))
54
+ if (disjoint) {
55
+ return { hits: vector.slice(0, k).map(h => ({ ...h, sources: ['vector'] })), guardApplied: true }
56
+ }
57
+ }
58
+ return {
59
+ hits: rrfFuse([{ source: 'bm25', hits: bm25 }, { source: 'vector', hits: vector }], k),
60
+ guardApplied: false,
61
+ }
62
+ }
63
+
44
64
  // Three modes. 'auto' (default): BM25 always; vector channel only when opted
45
65
  // in (vectorEnabled) AND the sidecar exists AND an embeddingModel is
46
66
  // configured — fail-open, any vector error degrades to BM25 with a single
47
- // stderr warning. 'bm25': lexical only, returns before any vector access.
67
+ // stderr warning. When both channels run but return disjoint top-k page sets,
68
+ // the lexicalGuard (config, default on) drops BM25 and ranks vector-only so a
69
+ // cross-language query's lexical noise cannot displace correct semantic hits.
70
+ // 'bm25': lexical only, returns before any vector access.
48
71
  // 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
49
72
  // vector error) throws instead of degrading, and vectorEnabled is ignored.
50
73
  export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry } = {}) {
51
74
  if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
52
75
  const bm25 = retrievePages(kbRoot, question, k)
53
- const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false })
76
+ const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false, guardApplied: false })
54
77
  if (retrieval === 'bm25') return asBm25()
55
78
  // 'hybrid' is an explicit request: prerequisites failing is an error, not a
56
79
  // silent degrade. 'auto' keeps the opt-in + fail-open contract unchanged.
@@ -77,7 +100,9 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
77
100
  // ENOENTs reading a vanished file).
78
101
  const valid = new Set(listWikiPages(kbRoot).filter(pg => !pg.error && !isInvalidated(pg)).map(pg => pg.relPath))
79
102
  const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
80
- return { hits: rrfFuse([{ source: 'bm25', hits: bm25 }, { source: 'vector', hits: vecHitsValid }], k), usedVector: true }
103
+ const { hits, guardApplied } = fuseChannels({ bm25, vector: vecHitsValid }, k,
104
+ { lexicalGuard: retrieval === 'auto' && loadKbConfig(kbRoot).lexicalGuard })
105
+ return { hits, usedVector: true, guardApplied }
81
106
  } catch (err) {
82
107
  if (retrieval === 'hybrid') throw err
83
108
  process.stderr.write(`warning: vector retrieval unavailable (${err.message}); falling back to BM25\n`)
package/src/hashing.mjs CHANGED
@@ -13,7 +13,12 @@ function fnv1a(str) {
13
13
  return h
14
14
  }
15
15
 
16
- export function minhashSignature(text, perms = 32) {
16
+ // 128 permutations, not 32: the near-dup estimate's standard error is ~sqrt(J(1-J)/perms),
17
+ // so at 32 perms a genuine near-duplicate (true Jaccard ~0.90) estimated as low as 0.84 and
18
+ // fell below NEAR_DUP_THRESHOLD (0.85), silently missing it (QA73-002). 128 perms tightens the
19
+ // estimate (SD ~0.026) so real near-dups clear the threshold; the cost is trivial next to the
20
+ // per-file sha256+read scan already does, and `_sig` is stripped before the plan is persisted.
21
+ export function minhashSignature(text, perms = 128) {
17
22
  const norm = text.toLowerCase().replace(/\s+/g, ' ')
18
23
  const shingles = new Set()
19
24
  for (let i = 0; i <= norm.length - 5; i++) shingles.add(norm.slice(i, i + 5))
package/src/indexer.mjs CHANGED
@@ -26,7 +26,13 @@ const inline = (s) => String(s ?? '').replace(/[\r\n]+/g, ' ')
26
26
  export function buildIndex(kbRoot) {
27
27
  const p = kbPaths(kbRoot)
28
28
  const cfg = loadKbConfig(kbRoot)
29
- const pages = listWikiPages(kbRoot).filter(pg => !pg.error)
29
+ const allPages = listWikiPages(kbRoot)
30
+ const pages = allPages.filter(pg => !pg.error)
31
+ // Pages with unparseable/missing frontmatter are excluded from every derived file. Return
32
+ // them so the CLI can warn instead of silently dropping a page — the README routes
33
+ // hand-editors to `index` (not `lint`), so a bad edit would otherwise vanish with no signal
34
+ // (QA73-001). lint still owns the detailed per-page report.
35
+ const skipped = allPages.filter(pg => pg.error).map(pg => ({ relPath: pg.relPath, error: pg.error }))
30
36
 
31
37
  // Preserve the pending section, bounded at the next `## ` heading — a greedy
32
38
  // match to EOF would swallow user-added sections into "pending" forever.
@@ -122,5 +128,5 @@ export function buildIndex(kbRoot) {
122
128
  ...pages.filter(pg => !isInvalidated(pg)).map(pg => `- [${inline(pg.data.title)}](wiki/${pg.relPath}): ${inline(pg.data.description)}`)].join('\n') + '\n'
123
129
  writeFileAtomic(p.llmsTxt, llms)
124
130
 
125
- return { pageCount: pages.length, topicsSplit }
131
+ return { pageCount: pages.length, topicsSplit, skipped }
126
132
  }
package/src/templates.mjs CHANGED
@@ -14,6 +14,7 @@ export const DEFAULT_CONFIG = {
14
14
  maxFileBytes: 52428800,
15
15
  bm25TitleWeight: 3,
16
16
  vectorEnabled: false,
17
+ lexicalGuard: true,
17
18
  relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
18
19
  }
19
20
 
@@ -38,6 +39,11 @@ export function loadKbConfig(kbRoot) {
38
39
  const n = Math.trunc(Number(merged[k]))
39
40
  merged[k] = Number.isFinite(n) && n >= 1 ? n : DEFAULT_CONFIG[k]
40
41
  }
42
+ // lexicalGuard is a boolean gate; a non-boolean override (string/number/null) must
43
+ // not read as truthy. Only an explicit true/false is honored — anything else falls
44
+ // back to the default. (Targeted, not a general boolean sweep: vectorEnabled keeps
45
+ // its existing truthy semantics to avoid changing behavior on malformed input.)
46
+ merged.lexicalGuard = typeof merged.lexicalGuard === 'boolean' ? merged.lexicalGuard : DEFAULT_CONFIG.lexicalGuard
41
47
  return merged
42
48
  }
43
49