@sdsrs/llm-wiki 0.6.7 → 0.7.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-07-11)
4
+
5
+ Two retrieval-default changes (hence a minor bump). Both are opt-out via
6
+ `wiki.config.json`; no KB migration, no re-index needed. Suite 210 → 213.
7
+
8
+ **What changes for you:**
9
+
10
+ - **BM25 now weights page titles.** A query term in a page's title outranks the
11
+ same term buried in another page's body. Measured on the dogfood KB (24 probes):
12
+ Recall@5 0.688 → 0.729, MRR 0.646 → 0.675 (the lift is entirely on fact-style
13
+ queries; cross-language and multi-hop are unchanged). **Opt out** by setting
14
+ `"bm25TitleWeight": 1` in `wiki.config.json` (default `3`) — `1` restores the
15
+ previous flat single-field indexing.
16
+ - **`ask` now budgets tokens pessimistically.** Page size against `askTokenBudget`
17
+ is now estimated at a worst-case ~2 chars/token (was ~4), so dense
18
+ markdown/code pages no longer silently overflow a small model context window.
19
+ The visible effect: on a tight budget, `ask` may load fewer whole pages before
20
+ trimming the lowest-ranked ones. **To include more pages**, raise
21
+ `"askTokenBudget"` in `wiki.config.json` (default `32000`) — pages are still
22
+ always sent whole, never truncated.
23
+
3
24
  ## 0.6.7 (2026-07-11)
4
25
 
5
26
  Contract/skill clarifications, an env-var convenience, and an explicit platform
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.6.7",
3
+ "version": "0.7.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
@@ -4,17 +4,22 @@ import { kbPaths } from './paths.mjs'
4
4
  import { loadKbConfig } from './templates.mjs'
5
5
  import { listWikiPages, isInvalidated } from './pages.mjs'
6
6
  import { buildBm25Index, searchBm25 } from './bm25.mjs'
7
- import { estimateTokens } from './scanner.mjs'
7
+ import { worstCaseTokens } from './scanner.mjs'
8
8
  import { loadLlmConfig, makeTransport } from './llm-config.mjs'
9
9
  import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
10
10
  import { embedTexts } from './embed.mjs'
11
11
  import { fetchWithRetry } from './retry.mjs'
12
12
 
13
13
  export function retrievePages(kbRoot, question, k = 6) {
14
+ // Title is a field: repeat it bm25TitleWeight times in the indexed text so a
15
+ // title term outweighs the same term buried in the body (measured +0.04 Recall@5
16
+ // on the dogfood KB at ×3). Set bm25TitleWeight: 1 in wiki.config.json to restore
17
+ // flat single-field indexing.
18
+ const w = Math.max(1, Math.trunc(loadKbConfig(kbRoot).bm25TitleWeight ?? 1))
14
19
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
15
20
  const idx = buildBm25Index(pages.map(p => ({
16
21
  id: p.relPath,
17
- text: [p.data.title, p.data.description, (p.data.tags ?? []).join(' '), p.body].join('\n'),
22
+ text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, (p.data.tags ?? []).join(' '), p.body].join('\n'),
18
23
  })))
19
24
  return searchBm25(idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
20
25
  }
@@ -164,7 +169,9 @@ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fet
164
169
  // off every lower-ranked page too (no greedy backfill with smaller pages).
165
170
  if (trimmed.length > 0) { trimmed.push(h.relPath); continue }
166
171
  const text = fs.readFileSync(path.join(p.wiki, h.relPath), 'utf8')
167
- const tokens = estimateTokens(text)
172
+ // Pessimistic estimate (worst-case ~2 chars/token) so dense pages don't overflow
173
+ // a small context window; raise askTokenBudget to include more pages per query.
174
+ const tokens = worstCaseTokens(text)
168
175
  if (loaded.length > 0 && used + tokens > kbCfg.askTokenBudget) { trimmed.push(h.relPath); continue }
169
176
  used += tokens
170
177
  loaded.push({ ...h, text })
package/src/scanner.mjs CHANGED
@@ -49,6 +49,17 @@ export function estimateTokens(text) {
49
49
  return Math.round(cjk / 1.6 + (text.length - cjk) / 4)
50
50
  }
51
51
 
52
+ // Pessimistic token estimate for budgeting model input (ask). Dense markdown/code
53
+ // runs ~2-3.5 chars/BPE-token, so estimateTokens' chars/4 under-counts and can
54
+ // overflow a small context window; assume ~2 chars/token for non-CJK and ~1
55
+ // token/char for CJK. (Mirror of embed.mjs's local worstCaseEmbedTokens, which
56
+ // stays local by its own note; this is the shared budgeting copy.)
57
+ export function worstCaseTokens(text) {
58
+ let cjk = 0
59
+ for (const ch of text) if (/[ -鿿豈-﫿]/.test(ch)) cjk++
60
+ return Math.round(cjk + (text.length - cjk) / 2)
61
+ }
62
+
52
63
  // Symlinked directories are not followed (loop safety) but are recorded in
53
64
  // `skippedDirs` so they surface in the scan report instead of vanishing silently.
54
65
  // Symlinked files are followed ONLY when their target resolves inside the source
package/src/templates.mjs CHANGED
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG = {
12
12
  linkStyle: 'wikilink',
13
13
  askTokenBudget: 32000,
14
14
  maxFileBytes: 52428800,
15
+ bm25TitleWeight: 3,
15
16
  vectorEnabled: false,
16
17
  relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
17
18
  }