@sdsrs/llm-wiki 0.4.1 → 0.5.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-07-11)
4
+
5
+ No breaking changes, no KB migration, defaults unchanged. One behavior fix you
6
+ may notice: `embed` no longer aborts the whole run when a page exceeds the
7
+ embedding model's input limit — the text sent to the API is capped (~8000
8
+ worst-case tokens, with a per-page stderr warning); the page file itself is
9
+ never modified. Pin `@sdsrs/llm-wiki@0.4.1` to keep the previous (aborting)
10
+ behavior.
11
+
12
+ - feat(ask): `locatePages`/`askKb` accept `retrieval: 'auto' | 'bm25' | 'hybrid'`
13
+ (`auto` = existing behavior exactly; `bm25` forces lexical-only; `hybrid`
14
+ forces fusion and errors when vector prerequisites are missing instead of
15
+ degrading). Programmatic surface only — CLI behavior unchanged. `chatCompletion`
16
+ is now an exported helper.
17
+ - fix(embed): oversized pages embed their head instead of killing the run —
18
+ input capped at ~8000 worst-case tokens (binary-search cut, lone-surrogate
19
+ trim at the boundary), per-page warning, incremental reuse unaffected for
20
+ pages under the cap.
21
+ - Eval harness completed (dev, in-repo `scripts/eval/`): four probe classes
22
+ (fact / multihop / xlang / none), answer-level eval (abstention honesty +
23
+ 3-dimension LLM-as-judge head-to-head with position-swap debiasing),
24
+ 50/150-page scale corpora generated from local markdown (zero network),
25
+ experimental graph-degree RRF arm (measured negative on ./kb — recorded in
26
+ `scripts/eval/README.md`, not promoted to `ask`), and `run-all.mjs`
27
+ producing a single markdown report.
28
+ - Measured 2026-07-11 (./kb 35pp + tiers, k=5): abstention on unanswerable
29
+ probes 100% for both bm25 and hybrid (n=4, zero fabrication); answer-level
30
+ head-to-head hybrid vs bm25 (n=24, judge gpt-5.1) — correctness 7-1-16,
31
+ citations 11-3-10, completeness 7-3-14 (wins-losses-ties); retrieval
32
+ Recall@5 bm25→hybrid: 35pp .688→.979, 50pp .600→1.000, 150pp .667→1.000
33
+ (no BM25-favored regime found in the 35–150 range).
34
+
3
35
  ## 0.4.1 (2026-07-11)
4
36
 
5
37
  Patch: all fixes, no new features, no deps, no KB migration. Defaults unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.4.1",
3
+ "version": "0.5.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
@@ -36,17 +36,25 @@ export function rrfFuse(lists, k) {
36
36
  // BM25 always; vector channel only when opted in (vectorEnabled) AND the
37
37
  // sidecar exists AND an embeddingModel is configured. Fail-open: any vector
38
38
  // error degrades to BM25 with a single stderr warning.
39
- export async function locatePages(kbRoot, question, { k = 6, fetchImpl } = {}) {
39
+ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto' } = {}) {
40
+ if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
40
41
  const bm25 = retrievePages(kbRoot, question, k)
41
42
  const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false })
42
- if (!loadKbConfig(kbRoot).vectorEnabled) return asBm25()
43
+ if (retrieval === 'bm25') return asBm25()
44
+ // 'hybrid' is an explicit request: prerequisites failing is an error, not a
45
+ // silent degrade. 'auto' keeps the opt-in + fail-open contract unchanged.
46
+ const unavailable = (what) => {
47
+ if (retrieval === 'hybrid') throw new Error(`retrieval 'hybrid' unavailable: ${what}`)
48
+ return asBm25()
49
+ }
50
+ if (retrieval === 'auto' && !loadKbConfig(kbRoot).vectorEnabled) return asBm25()
43
51
  const store = loadVectorStore(kbRoot)
44
- if (!store) return asBm25()
52
+ if (!store) return unavailable('no wiki/.vectors.json — run `llm-wiki embed` first')
45
53
  const cfg = loadLlmConfig(kbRoot)
46
- if (!cfg?.embeddingModel) return asBm25()
54
+ if (!cfg?.embeddingModel) return unavailable('no embeddingModel in ~/.llm-wiki/config.json')
47
55
  // A store built by a different embeddingModel lives in a foreign vector space:
48
56
  // fusing it produces silent garbage, so treat it as missing (not a failure).
49
- if (store.model !== cfg.embeddingModel) return asBm25()
57
+ if (store.model !== cfg.embeddingModel) return unavailable(`vector store was built with "${store.model}" but config says "${cfg.embeddingModel}" — re-run \`llm-wiki embed\``)
50
58
  try {
51
59
  const t = fetchImpl ? { fetchImpl, dispatcher: undefined } : await makeTransport()
52
60
  const [qv] = await embedTexts(cfg, t, [question])
@@ -60,12 +68,13 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl } = {}) {
60
68
  const vecHitsValid = vecHits.filter(h => valid.has(h.relPath))
61
69
  return { hits: rrfFuse([{ source: 'bm25', hits: bm25 }, { source: 'vector', hits: vecHitsValid }], k), usedVector: true }
62
70
  } catch (err) {
71
+ if (retrieval === 'hybrid') throw err
63
72
  process.stderr.write(`warning: vector retrieval unavailable (${err.message}); falling back to BM25\n`)
64
73
  return asBm25()
65
74
  }
66
75
  }
67
76
 
68
- async function chatCompletion(cfg, t, messages) {
77
+ export async function chatCompletion(cfg, t, messages) {
69
78
  const res = await t.fetchImpl(`${cfg.baseURL.replace(/\/$/, '')}/chat/completions`, {
70
79
  method: 'POST',
71
80
  headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
@@ -118,9 +127,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
118
127
  return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
119
128
  }
120
129
 
121
- export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl } = {}) {
130
+ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto' } = {}) {
122
131
  const p = kbPaths(kbRoot)
123
- let { hits } = await locatePages(kbRoot, question, { k, fetchImpl })
132
+ let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval })
124
133
  if (retrieveOnly) return { pages: hits, answer: null }
125
134
  let validIds
126
135
  if (hits.length === 0) {
package/src/embed.mjs CHANGED
@@ -4,6 +4,44 @@ import { sha256Text } from './hashing.mjs'
4
4
  import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
5
5
 
6
6
  const BATCH = 64
7
+ // Safety margin under the 8192-token input limit of common embedding models
8
+ // (e.g. text-embedding-3-small). We cap ONLY the text sent to the embedding API;
9
+ // whole-page retrieval is unaffected — the page file and its BM25 text are never
10
+ // touched, only the vector's input is truncated so one huge page can't abort the run.
11
+ const EMBED_TOKEN_CAP = 8000
12
+
13
+ // Worst-case token count for the embedding API — deliberately NOT scanner's
14
+ // estimateTokens (chars/4), which underestimates dense markdown/code where real
15
+ // BPE runs ~2-3.5 chars/token and lets 8192-token pages slip through. This assumes
16
+ // the pessimistic ~2 chars/token for non-CJK and 1 token/char for CJK, halving
17
+ // usable capacity for English prose — the accepted tradeoff for a deterministic,
18
+ // provider-independent cap. A location vector doesn't need the page's tail.
19
+ // (CJK char class copied from scanner.mjs estimateTokens — kept local on purpose.)
20
+ function worstCaseEmbedTokens(text) {
21
+ let cjk = 0
22
+ for (const ch of text) if (/[ -鿿豈-﫿]/.test(ch)) cjk++
23
+ return cjk + (text.length - cjk) / 2
24
+ }
25
+
26
+ // Largest prefix of `text` whose worstCaseEmbedTokens is within EMBED_TOKEN_CAP.
27
+ // worstCaseEmbedTokens is monotonic non-decreasing in prefix length, so a binary
28
+ // search over the string finds the cut with no dependencies.
29
+ function capEmbedText(text) {
30
+ if (worstCaseEmbedTokens(text) <= EMBED_TOKEN_CAP) return text
31
+ let lo = 0, hi = text.length
32
+ while (lo < hi) {
33
+ const mid = Math.ceil((lo + hi) / 2)
34
+ if (worstCaseEmbedTokens(text.slice(0, mid)) <= EMBED_TOKEN_CAP) lo = mid
35
+ else hi = mid - 1
36
+ }
37
+ // Slicing by UTF-16 code unit can split an astral char (e.g. emoji, CJK ext-B),
38
+ // leaving a lone high surrogate (0xD800–0xDBFF) at the boundary. JSON.stringify
39
+ // escapes it as a literal \uD83D and a strict provider can reject the request —
40
+ // the exact abort this cap exists to prevent. Drop a dangling high surrogate.
41
+ const last = text.charCodeAt(lo - 1)
42
+ if (lo > 0 && last >= 0xD800 && last <= 0xDBFF) lo--
43
+ return text.slice(0, lo)
44
+ }
7
45
 
8
46
  export async function embedTexts(cfg, t, texts) {
9
47
  const res = await t.fetchImpl(`${cfg.baseURL.replace(/\/$/, '')}/embeddings`, {
@@ -35,9 +73,12 @@ export async function embedKb(kbRoot, { fetchImpl } = {}) {
35
73
  const nextPages = {}
36
74
  let reused = 0
37
75
  for (const pg of pages) {
38
- const text = pageEmbedText(pg)
76
+ const raw = pageEmbedText(pg)
77
+ const text = capEmbedText(raw)
78
+ const truncated = text !== raw
39
79
  const hash = sha256Text(text)
40
80
  if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
81
+ if (truncated) process.stderr.write(`warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
41
82
  jobs.push({ relPath: pg.relPath, text, hash })
42
83
  }
43
84
  let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null