@sdsrs/llm-wiki 0.6.7 → 0.7.1
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 +48 -0
- package/bin/llm-wiki.mjs +4 -0
- package/package.json +1 -1
- package/src/ask.mjs +24 -9
- package/src/embed.mjs +13 -5
- package/src/indexer.mjs +2 -2
- package/src/lint.mjs +8 -5
- package/src/pages.mjs +12 -0
- package/src/scanner.mjs +11 -0
- package/src/status.mjs +2 -2
- package/src/templates.mjs +1 -0
- package/src/vector.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 (2026-07-11)
|
|
4
|
+
|
|
5
|
+
Four defects found by end-to-end dogfooding — one crash-class fix plus three
|
|
6
|
+
robustness/UX fixes. No config change, no KB migration, no retrieval-default
|
|
7
|
+
change. Suite 213 → 221.
|
|
8
|
+
|
|
9
|
+
**Fixes:**
|
|
10
|
+
|
|
11
|
+
- **A single malformed page no longer breaks the whole KB.** A frontmatter list
|
|
12
|
+
field authored as a bare scalar (`tags: cache` instead of `tags: [cache]`)
|
|
13
|
+
parses as a string, which slipped past the `?? []` guards and then crashed
|
|
14
|
+
`ask`/`embed` (`.join` on a string) or was iterated character-by-character
|
|
15
|
+
(`lint` noise; `index` synthesized a bogus graph edge per character, corrupting
|
|
16
|
+
`graph.json`). List fields now route through an `Array.isArray` guard in every
|
|
17
|
+
consumer, so one bad page can no longer take down retrieval or the graph.
|
|
18
|
+
`lint` now flags `tags must be a YAML list` so the author can fix the shape.
|
|
19
|
+
- **`lint` contradiction-scan skips invalidated pages.** Retired knowledge is
|
|
20
|
+
already excluded from stale-scan and the orphan rule; the shared-tag
|
|
21
|
+
contradiction scan now matches, so you are never asked to reconcile a
|
|
22
|
+
contradiction against a dead page.
|
|
23
|
+
- **`convert` exits non-zero when every file fails.** `converted 0, failed N`
|
|
24
|
+
used to exit `0`, hiding a total failure from CI/scripts. It now exits `1` on
|
|
25
|
+
total failure; a partial success (some files converted) still exits `0`.
|
|
26
|
+
- **Network errors name the endpoint.** A mistyped `baseURL` or offline host used
|
|
27
|
+
to surface undici's opaque `fetch failed`; `ask`/`embed` now report
|
|
28
|
+
`could not reach the LLM/embedding endpoint <url> — check baseURL/network`.
|
|
29
|
+
|
|
30
|
+
## 0.7.0 (2026-07-11)
|
|
31
|
+
|
|
32
|
+
Two retrieval-default changes (hence a minor bump). Both are opt-out via
|
|
33
|
+
`wiki.config.json`; no KB migration, no re-index needed. Suite 210 → 213.
|
|
34
|
+
|
|
35
|
+
**What changes for you:**
|
|
36
|
+
|
|
37
|
+
- **BM25 now weights page titles.** A query term in a page's title outranks the
|
|
38
|
+
same term buried in another page's body. Measured on the dogfood KB (24 probes):
|
|
39
|
+
Recall@5 0.688 → 0.729, MRR 0.646 → 0.675 (the lift is entirely on fact-style
|
|
40
|
+
queries; cross-language and multi-hop are unchanged). **Opt out** by setting
|
|
41
|
+
`"bm25TitleWeight": 1` in `wiki.config.json` (default `3`) — `1` restores the
|
|
42
|
+
previous flat single-field indexing.
|
|
43
|
+
- **`ask` now budgets tokens pessimistically.** Page size against `askTokenBudget`
|
|
44
|
+
is now estimated at a worst-case ~2 chars/token (was ~4), so dense
|
|
45
|
+
markdown/code pages no longer silently overflow a small model context window.
|
|
46
|
+
The visible effect: on a tight budget, `ask` may load fewer whole pages before
|
|
47
|
+
trimming the lowest-ranked ones. **To include more pages**, raise
|
|
48
|
+
`"askTokenBudget"` in `wiki.config.json` (default `32000`) — pages are still
|
|
49
|
+
always sent whole, never truncated.
|
|
50
|
+
|
|
3
51
|
## 0.6.7 (2026-07-11)
|
|
4
52
|
|
|
5
53
|
Contract/skill clarifications, an env-var convenience, and an explicit platform
|
package/bin/llm-wiki.mjs
CHANGED
|
@@ -50,6 +50,10 @@ program.command('convert')
|
|
|
50
50
|
const r = await runConvertPlan(opts.kb)
|
|
51
51
|
console.log(`converted ${r.converted.length}, failed ${r.failed.length}`)
|
|
52
52
|
for (const f of r.failed) console.log(` FAILED ${f.src}: ${f.warnings.join('; ')}`)
|
|
53
|
+
// Total failure (nothing converted, at least one error) is a hard error a
|
|
54
|
+
// pipeline must catch — exit 1. A partial success keeps exit 0 so a messy dir
|
|
55
|
+
// with a few unconvertible junk files doesn't break an otherwise-good run.
|
|
56
|
+
if (r.converted.length === 0 && r.failed.length > 0) process.exit(1)
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
program.command('index')
|
package/package.json
CHANGED
package/src/ask.mjs
CHANGED
|
@@ -2,19 +2,24 @@ 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 } from './pages.mjs'
|
|
5
|
+
import { listWikiPages, isInvalidated, asList } from './pages.mjs'
|
|
6
6
|
import { buildBm25Index, searchBm25 } from './bm25.mjs'
|
|
7
|
-
import {
|
|
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
|
|
22
|
+
text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(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
|
}
|
|
@@ -79,11 +84,19 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
export async function chatCompletion(cfg, t, messages) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
const url = `${cfg.baseURL.replace(/\/$/, '')}/chat/completions`
|
|
88
|
+
let res
|
|
89
|
+
try {
|
|
90
|
+
res = await fetchWithRetry(t.fetchImpl, url, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
|
|
93
|
+
body: JSON.stringify({ model: cfg.model, messages }),
|
|
94
|
+
}, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Node's undici surfaces a bare "fetch failed" for DNS/refused/timeout — name
|
|
97
|
+
// the endpoint so a mistyped baseURL or offline host is diagnosable, not opaque.
|
|
98
|
+
throw new Error(`could not reach the LLM endpoint ${url} — check baseURL/network in ~/.llm-wiki/config.json (${err.message})`)
|
|
99
|
+
}
|
|
87
100
|
if (!res.ok) {
|
|
88
101
|
let body = ''
|
|
89
102
|
try { body = (await res.text()).slice(0, 200) } catch { /* body unreadable; status alone */ }
|
|
@@ -164,7 +177,9 @@ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fet
|
|
|
164
177
|
// off every lower-ranked page too (no greedy backfill with smaller pages).
|
|
165
178
|
if (trimmed.length > 0) { trimmed.push(h.relPath); continue }
|
|
166
179
|
const text = fs.readFileSync(path.join(p.wiki, h.relPath), 'utf8')
|
|
167
|
-
|
|
180
|
+
// Pessimistic estimate (worst-case ~2 chars/token) so dense pages don't overflow
|
|
181
|
+
// a small context window; raise askTokenBudget to include more pages per query.
|
|
182
|
+
const tokens = worstCaseTokens(text)
|
|
168
183
|
if (loaded.length > 0 && used + tokens > kbCfg.askTokenBudget) { trimmed.push(h.relPath); continue }
|
|
169
184
|
used += tokens
|
|
170
185
|
loaded.push({ ...h, text })
|
package/src/embed.mjs
CHANGED
|
@@ -45,11 +45,19 @@ function capEmbedText(text) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export async function embedTexts(cfg, t, texts) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
const url = `${cfg.baseURL.replace(/\/$/, '')}/embeddings`
|
|
49
|
+
let res
|
|
50
|
+
try {
|
|
51
|
+
res = await fetchWithRetry(t.fetchImpl, url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
|
|
54
|
+
body: JSON.stringify({ model: cfg.embeddingModel, input: texts }),
|
|
55
|
+
}, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Mirror chatCompletion: turn undici's opaque "fetch failed" into a diagnosable
|
|
58
|
+
// message naming the endpoint (mistyped baseURL / offline host / DNS failure).
|
|
59
|
+
throw new Error(`could not reach the embedding endpoint ${url} — check baseURL/network in ~/.llm-wiki/config.json (${err.message})`)
|
|
60
|
+
}
|
|
53
61
|
if (!res.ok) {
|
|
54
62
|
let body = ''
|
|
55
63
|
try { body = (await res.text()).slice(0, 200) } catch { /* status alone */ }
|
package/src/indexer.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, RELATION_CONFIDENCES } from './pages.mjs'
|
|
5
|
+
import { listWikiPages, isInvalidated, asList, RELATION_CONFIDENCES } from './pages.mjs'
|
|
6
6
|
|
|
7
7
|
const WIKILINK_RE = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g
|
|
8
8
|
|
|
@@ -96,7 +96,7 @@ export function buildIndex(kbRoot) {
|
|
|
96
96
|
for (const target of extractWikilinks(pg.body)) {
|
|
97
97
|
if (ids.has(target)) edges.push({ source: id, target, type: 'wikilink', confidence: 'inferred' })
|
|
98
98
|
}
|
|
99
|
-
for (const src of pg.data.sources
|
|
99
|
+
for (const src of asList(pg.data.sources)) edges.push({ source: id, target: String(src), type: 'source', confidence: 'extracted' })
|
|
100
100
|
if (pg.data.superseded_by && ids.has(String(pg.data.superseded_by))) {
|
|
101
101
|
edges.push({ source: id, target: String(pg.data.superseded_by), type: 'superseded_by', confidence: 'extracted' })
|
|
102
102
|
}
|
package/src/lint.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, validatePage, isInvalidated, PAGE_STATUSES, RELATION_CONFIDENCES } from './pages.mjs'
|
|
5
|
+
import { listWikiPages, validatePage, isInvalidated, asList, PAGE_STATUSES, RELATION_CONFIDENCES } from './pages.mjs'
|
|
6
6
|
import { extractWikilinks, buildIndex } from './indexer.mjs'
|
|
7
7
|
import { loadManifest } from './manifest.mjs'
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
|
27
27
|
if (!ids.has(target)) mechanical.push({ rule: 'broken-wikilink', path: pg.relPath, detail: `-> ${target}` })
|
|
28
28
|
else incoming.set(target, (incoming.get(target) ?? 0) + 1)
|
|
29
29
|
}
|
|
30
|
-
for (const src of pg.data.sources
|
|
30
|
+
for (const src of asList(pg.data.sources)) {
|
|
31
31
|
if (!fs.existsSync(path.join(kbRoot, String(src)))) mechanical.push({ rule: 'missing-raw-source', path: pg.relPath, detail: String(src) })
|
|
32
32
|
}
|
|
33
33
|
if (pg.data.status !== undefined && !PAGE_STATUSES.includes(pg.data.status)) {
|
|
@@ -90,8 +90,11 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
|
90
90
|
}
|
|
91
91
|
const byTag = new Map()
|
|
92
92
|
for (const pg of pages) {
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// Invalidated pages are retired knowledge: they must drop out of the semantic
|
|
94
|
+
// worklist just like stale-scan (below) and the orphan rule already do, so the
|
|
95
|
+
// agent is never asked to reconcile a "contradiction" against a dead page.
|
|
96
|
+
if (pg.error || isInvalidated(pg)) continue
|
|
97
|
+
for (const tag of asList(pg.data.tags)) {
|
|
95
98
|
if (!byTag.has(tag)) byTag.set(tag, [])
|
|
96
99
|
byTag.get(tag).push(pg.relPath)
|
|
97
100
|
}
|
|
@@ -115,7 +118,7 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
|
115
118
|
if (pg.error || isInvalidated(pg)) continue
|
|
116
119
|
const updated = String(pg.data.updated ?? '')
|
|
117
120
|
if (!updated) continue
|
|
118
|
-
for (const src of pg.data.sources
|
|
121
|
+
for (const src of asList(pg.data.sources)) {
|
|
119
122
|
const conv = convertedAtByRaw.get(String(src))
|
|
120
123
|
if (conv && conv > updated) semantic.push({ task: 'stale-scan', detail: `${pg.relPath}: ${src} reconverted ${conv}, page updated ${updated}` })
|
|
121
124
|
}
|
package/src/pages.mjs
CHANGED
|
@@ -10,6 +10,14 @@ export const PAGE_STATUSES = ['active', 'invalidated']
|
|
|
10
10
|
|
|
11
11
|
export const RELATION_CONFIDENCES = ['extracted', 'inferred', 'ambiguous']
|
|
12
12
|
|
|
13
|
+
// A frontmatter list field (tags, sources) authored as a bare scalar — `tags: cache`
|
|
14
|
+
// instead of `tags: [cache]` — is parsed by YAML as a string. That silently passes a
|
|
15
|
+
// `?? []` guard and then crashes `.join()` (retrieval/embed) or iterates the string
|
|
16
|
+
// character-by-character (lint noise, and indexer synthesizing a bogus graph edge per
|
|
17
|
+
// char). Every consumer routes list fields through this so one malformed page cannot
|
|
18
|
+
// DoS retrieval or corrupt the graph; validatePage/lint still flag the bad shape.
|
|
19
|
+
export const asList = (v) => (Array.isArray(v) ? v : [])
|
|
20
|
+
|
|
13
21
|
export function isInvalidated(page) {
|
|
14
22
|
return page.data?.status === 'invalidated'
|
|
15
23
|
}
|
|
@@ -40,5 +48,9 @@ export function validatePage(page) {
|
|
|
40
48
|
if (page.data[k] === undefined || page.data[k] === null || page.data[k] === '') issues.push(`missing field: ${k}`)
|
|
41
49
|
}
|
|
42
50
|
if (!Array.isArray(page.data.sources)) issues.push('missing field: sources (evidence chain)')
|
|
51
|
+
// tags is caught as present by the REQUIRED loop above even when authored as a
|
|
52
|
+
// bare scalar (`tags: cache`); flag the wrong shape explicitly so the author fixes
|
|
53
|
+
// it rather than silently getting a page with no searchable tags.
|
|
54
|
+
if (page.data.tags !== undefined && !Array.isArray(page.data.tags)) issues.push('tags must be a YAML list')
|
|
43
55
|
return issues
|
|
44
56
|
}
|
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/status.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { kbPaths } from './paths.mjs'
|
|
4
|
-
import { listWikiPages } from './pages.mjs'
|
|
4
|
+
import { listWikiPages, asList } from './pages.mjs'
|
|
5
5
|
import { scanSource } from './scanner.mjs'
|
|
6
6
|
import { loadManifest, diffManifest } from './manifest.mjs'
|
|
7
7
|
|
|
@@ -27,7 +27,7 @@ export async function statusKb(kbRoot, srcDir) {
|
|
|
27
27
|
for (const pg of listWikiPages(kbRoot)) {
|
|
28
28
|
if (pg.error) continue
|
|
29
29
|
const id = pg.relPath.replace(/\.md$/, '')
|
|
30
|
-
for (const src of pg.data.sources
|
|
30
|
+
for (const src of asList(pg.data.sources)) {
|
|
31
31
|
const raw = String(src)
|
|
32
32
|
referenced.add(raw)
|
|
33
33
|
if (!pagesByRaw.has(raw)) pagesByRaw.set(raw, [])
|
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
|
}
|
package/src/vector.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { kbPaths } from './paths.mjs'
|
|
4
4
|
import { readJsonFile } from './json.mjs'
|
|
5
|
+
import { asList } from './pages.mjs'
|
|
5
6
|
|
|
6
7
|
export function normalize(vec) {
|
|
7
8
|
let s = 0
|
|
@@ -13,7 +14,7 @@ export function normalize(vec) {
|
|
|
13
14
|
|
|
14
15
|
// Same fields BM25 indexes (src/ask.mjs retrievePages) so both channels see one text.
|
|
15
16
|
export function pageEmbedText(pg) {
|
|
16
|
-
return [pg.data.title ?? '', pg.data.description ?? '', (pg.data.tags
|
|
17
|
+
return [pg.data.title ?? '', pg.data.description ?? '', asList(pg.data.tags).join(' '), pg.body].join('\n')
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function vectorStorePath(kbRoot) {
|