@sdsrs/llm-wiki 0.7.1 → 0.7.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 CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3 (2026-07-12)
4
+
5
+ Two robustness fixes from the final targeted dogfooding sweep. No config schema
6
+ change, no KB migration, no retrieval-default change. Suite 228 → 230.
7
+
8
+ **Fixes:**
9
+
10
+ - **`graph`/exports name the file on a corrupt `graph.json`.** `loadGraph` was the
11
+ last JSON reader still using a bare `JSON.parse`: a corrupt `wiki/graph.json`
12
+ threw an unqualified `Unexpected token` SyntaxError, and a valid-JSON-but-wrong-
13
+ shape file like `{}` crashed on `graph.nodes.map`. It now routes through the
14
+ shared `readJsonFile` (names the file) and guards the nodes/edges shape, so both
15
+ corruption modes yield a named, actionable "rerun `llm-wiki index`" error.
16
+ - **`.scan-plan.json` is written atomically.** `scan` wrote the plan with a direct
17
+ `writeFileSync`, but `convert` reads it from a separate process — the same
18
+ truncate-then-write torn read that 0.7.2 fixed for `graph.json`. It (and
19
+ `.lint-report.json`) now write via the shared temp-and-rename `writeFileAtomic`,
20
+ so every JSON state file another process can read is crash-safe.
21
+
22
+ ## 0.7.2 (2026-07-12)
23
+
24
+ Three defects found by continued end-to-end dogfooding — two robustness/DoS
25
+ guards plus one UX fix. No config schema change, no KB migration, no
26
+ retrieval-default change. Suite 221 → 228.
27
+
28
+ **Fixes:**
29
+
30
+ - **Malformed numeric config can no longer crash retrieval or hang scan.**
31
+ `wiki.config.json` is shallow-merged as-is, so a numeric key could arrive as a
32
+ string/0/negative/NaN and reach a scalar consumer: `bm25TitleWeight: "high"`
33
+ hit `Array(NaN)` → `RangeError` on *every* `ask`/`search`/MCP query (a huge int
34
+ → per-page `Array(n).fill` OOM), and `batchSize: 0` made `scan`'s batch loop
35
+ (`i += batchSize`) spin forever. `loadKbConfig` now coerces every numeric key
36
+ back to a finite integer ≥ 1 (falling back to its default), and
37
+ `bm25TitleWeight` is additionally capped at its consumer. Legitimate overrides
38
+ are untouched.
39
+ - **Derived stores are written atomically.** `buildIndex` wrote `graph.json` /
40
+ `index.md` / `llms.txt` / topic files with a direct `writeFileSync`, which
41
+ truncates the target before writing — a reader running concurrently (a
42
+ long-lived MCP `graph` tool, `ask`'s `llms.txt` fallback) could read a torn
43
+ `graph.json` and crash on `JSON.parse`. All derived stores now write via a
44
+ shared temp-and-rename `writeFileAtomic` (the pattern `saveManifest` /
45
+ `saveVectorStore` already used), so a concurrent reader only ever sees the whole
46
+ old or whole new file.
47
+ - **A source that converts to an empty page is no longer silent.** A
48
+ scanned/image-only PDF, an empty DOCX, or a blank `.txt` extracts to no text;
49
+ `convert` still writes the (empty) raw page and counts it as converted, but now
50
+ prints `WARN <src>: converted to an empty page — no extractable text` so the
51
+ user isn't misled into thinking real content was ingested.
52
+
3
53
  ## 0.7.1 (2026-07-11)
4
54
 
5
55
  Four defects found by end-to-end dogfooding — one crash-class fix plus three
package/bin/llm-wiki.mjs CHANGED
@@ -50,6 +50,9 @@ 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
+ // A converted-but-empty page (scanned PDF, empty DOCX) is not a failure, but the
54
+ // user must see it — an unflagged blank raw page reads as "ingested" when it isn't.
55
+ for (const c of r.converted) if (c.warnings) console.log(` WARN ${c.src}: ${c.warnings.join('; ')}`)
53
56
  // Total failure (nothing converted, at least one error) is a hard error a
54
57
  // pipeline must catch — exit 1. A partial success keeps exit 0 so a messy dir
55
58
  // with a few unconvertible junk files doesn't break an otherwise-good run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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
@@ -15,7 +15,9 @@ export function retrievePages(kbRoot, question, k = 6) {
15
15
  // title term outweighs the same term buried in the body (measured +0.04 Recall@5
16
16
  // on the dogfood KB at ×3). Set bm25TitleWeight: 1 in wiki.config.json to restore
17
17
  // flat single-field indexing.
18
- const w = Math.max(1, Math.trunc(loadKbConfig(kbRoot).bm25TitleWeight ?? 1))
18
+ // loadKbConfig guarantees a finite integer >= 1; cap the upper bound so a large
19
+ // value can't materialize a giant per-page array (Array(w).fill) and OOM retrieval.
20
+ const w = Math.min(25, loadKbConfig(kbRoot).bm25TitleWeight)
19
21
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
20
22
  const idx = buildBm25Index(pages.map(p => ({
21
23
  id: p.relPath,
@@ -69,7 +69,7 @@ export async function runConvertPlan(kbRoot) {
69
69
  convertedAt: new Date().toISOString().slice(0, 10),
70
70
  ...(originalRel ? { original: originalRel } : {}),
71
71
  }
72
- converted.push({ src: rel, raw: rawRel })
72
+ converted.push({ src: rel, raw: rawRel, ...(warnings.length ? { warnings } : {}) })
73
73
  }
74
74
  saveManifest(kbRoot, manifest)
75
75
  return { converted, failed }
package/src/convert.mjs CHANGED
@@ -19,6 +19,19 @@ function titleFrom(markdown, fallback) {
19
19
  return m ? m[1].trim() : fallback
20
20
  }
21
21
 
22
+ // A source can convert successfully yet yield no text — a scanned/image-only PDF, an
23
+ // empty DOCX, a blank .txt. That is still a converted page (markdown '' is not null, so
24
+ // the deliberate empty-file handling downstream is untouched), but writing a blank raw
25
+ // page while reporting "converted" silently misleads the user. Attach a warning the CLI
26
+ // can surface. `# heading`-prefixed HTML output never trims to empty, so this fires only
27
+ // on genuinely contentless extractions.
28
+ function result(markdown, title, warnings) {
29
+ if (markdown !== null && markdown.trim() === '') {
30
+ warnings.push('converted to an empty page — no extractable text (scanned/image-only PDF or empty document?)')
31
+ }
32
+ return { markdown, title, warnings }
33
+ }
34
+
22
35
  export async function convertFile(srcPath) {
23
36
  const ext = path.extname(srcPath).toLowerCase()
24
37
  const base = path.basename(srcPath)
@@ -26,11 +39,11 @@ export async function convertFile(srcPath) {
26
39
  try {
27
40
  if (ext === '.md' || ext === '.markdown') {
28
41
  const md = fs.readFileSync(srcPath, 'utf8')
29
- return { markdown: md, title: titleFrom(md, base), warnings }
42
+ return result(md, titleFrom(md, base), warnings)
30
43
  }
31
44
  if (ext === '.txt') {
32
45
  const md = fs.readFileSync(srcPath, 'utf8')
33
- return { markdown: md, title: titleFrom(md, base), warnings }
46
+ return result(md, titleFrom(md, base), warnings)
34
47
  }
35
48
  if (ext === '.html' || ext === '.htm') {
36
49
  const { JSDOM } = await import('jsdom')
@@ -45,17 +58,17 @@ export async function convertFile(srcPath) {
45
58
  }
46
59
  const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' })
47
60
  const md = td.turndown(article.content)
48
- return { markdown: `# ${article.title || base}\n\n${md}`, title: article.title || base, warnings }
61
+ return result(`# ${article.title || base}\n\n${md}`, article.title || base, warnings)
49
62
  }
50
63
  if (ext === '.pdf') {
51
64
  const { PDFParse } = await import('pdf-parse')
52
65
  const { text } = await new PDFParse({ data: fs.readFileSync(srcPath) }).getText()
53
- return { markdown: text, title: titleFrom(text, base), warnings }
66
+ return result(text, titleFrom(text, base), warnings)
54
67
  }
55
68
  if (ext === '.docx') {
56
69
  const mammoth = await import('mammoth')
57
70
  const { value } = await mammoth.extractRawText({ path: srcPath })
58
- return { markdown: value, title: titleFrom(value, base), warnings }
71
+ return result(value, titleFrom(value, base), warnings)
59
72
  }
60
73
  warnings.push(`unsupported extension: ${ext}`)
61
74
  return { markdown: null, title: base, warnings }
package/src/export.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 { listWikiPages } from './pages.mjs'
5
+ import { readJsonFile } from './json.mjs'
5
6
 
6
7
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
7
8
 
@@ -16,7 +17,18 @@ const cyEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').r
16
17
  export function loadGraph(kbRoot) {
17
18
  const p = kbPaths(kbRoot)
18
19
  if (!fs.existsSync(p.graphJson)) throw new Error('wiki/graph.json not found — run `llm-wiki index` first.')
19
- const graph = JSON.parse(fs.readFileSync(p.graphJson, 'utf8'))
20
+ // graph.json is a derived store — route it through readJsonFile (names the file)
21
+ // rather than a bare JSON.parse whose SyntaxError leaks no path, and guard the shape
22
+ // so a hand-corrupted-but-valid `{}` doesn't crash on `graph.nodes.map` (undefined).
23
+ let graph
24
+ try {
25
+ graph = readJsonFile(p.graphJson)
26
+ } catch (err) {
27
+ throw new Error(`${err.message} — rerun \`llm-wiki index\` to rebuild the graph`)
28
+ }
29
+ if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
30
+ throw new Error('wiki/graph.json has an unexpected shape (needs nodes/edges arrays) — rerun `llm-wiki index`')
31
+ }
20
32
  const ids = new Set(graph.nodes.map(n => n.id))
21
33
  for (const e of graph.edges) {
22
34
  for (const end of [e.source, e.target]) {
package/src/indexer.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import { kbPaths } from './paths.mjs'
4
4
  import { loadKbConfig } from './templates.mjs'
5
5
  import { listWikiPages, isInvalidated, asList, RELATION_CONFIDENCES } from './pages.mjs'
6
+ import { writeFileAtomic } from './json.mjs'
6
7
 
7
8
  const WIKILINK_RE = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g
8
9
 
@@ -69,7 +70,7 @@ export function buildIndex(kbRoot) {
69
70
  const written = new Set()
70
71
  for (const [type, list] of Object.entries(byType)) {
71
72
  if (!list.length) continue
72
- fs.writeFileSync(path.join(p.topics, `${type}.md`), `# ${SECTION_TITLES[type]}\n\n${list.map(line).join('\n')}\n`)
73
+ writeFileAtomic(path.join(p.topics, `${type}.md`), `# ${SECTION_TITLES[type]}\n\n${list.map(line).join('\n')}\n`)
73
74
  written.add(`${type}.md`)
74
75
  indexBody += `\n## ${SECTION_TITLES[type]}\nSee [[topics/${type}]] (${list.length} pages)\n`
75
76
  }
@@ -81,7 +82,7 @@ export function buildIndex(kbRoot) {
81
82
  indexBody += `\n## ${SECTION_TITLES[type]}\n${list.map(line).join('\n')}\n`
82
83
  }
83
84
  }
84
- fs.writeFileSync(p.indexMd, `${indexBody}\n${pending}${tail}`)
85
+ writeFileAtomic(p.indexMd, `${indexBody}\n${pending}${tail}`)
85
86
 
86
87
  const ids = new Set(pages.map(pg => pg.relPath.replace(/\.md$/, '')))
87
88
  const nodes = pages.map(pg => ({
@@ -114,12 +115,12 @@ export function buildIndex(kbRoot) {
114
115
  edges.push({ source: id, target, type: String(rel.type), confidence })
115
116
  }
116
117
  }
117
- fs.writeFileSync(p.graphJson, JSON.stringify({ nodes, edges }, null, 2) + '\n')
118
+ writeFileAtomic(p.graphJson, JSON.stringify({ nodes, edges }, null, 2) + '\n')
118
119
 
119
120
  const kbName = path.basename(path.resolve(kbRoot))
120
121
  const llms = [`# ${kbName}`, '', `> llm_wiki knowledge base. Read wiki/index.md first, then open full pages.`, '', '## Pages',
121
122
  ...pages.filter(pg => !isInvalidated(pg)).map(pg => `- [${inline(pg.data.title)}](wiki/${pg.relPath}): ${inline(pg.data.description)}`)].join('\n') + '\n'
122
- fs.writeFileSync(p.llmsTxt, llms)
123
+ writeFileAtomic(p.llmsTxt, llms)
123
124
 
124
125
  return { pageCount: pages.length, topicsSplit }
125
126
  }
package/src/json.mjs CHANGED
@@ -14,3 +14,15 @@ export function readJsonFile(file, { redactContents = false } = {}) {
14
14
  throw new Error(redactContents ? `${file}: invalid JSON` : `${file}: invalid JSON (${err.message})`)
15
15
  }
16
16
  }
17
+
18
+ // Atomic file write: `fs.writeFileSync` truncates the target *before* writing, so a
19
+ // concurrent reader (a long-lived MCP server, an `ask` fallback) can observe an empty
20
+ // or half-written file — for JSON stores that means a `JSON.parse` crash. Write a temp
21
+ // sibling and rename (atomic on the POSIX filesystems this tool supports) so a reader
22
+ // only ever sees the whole old file or the whole new one. Single source of the pattern
23
+ // that manifest/vector already used and buildIndex's derived stores previously missed.
24
+ export function writeFileAtomic(file, data) {
25
+ const tmp = `${file}.tmp`
26
+ fs.writeFileSync(tmp, data)
27
+ fs.renameSync(tmp, file)
28
+ }
package/src/lint.mjs CHANGED
@@ -5,6 +5,7 @@ import { loadKbConfig } from './templates.mjs'
5
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
+ import { writeFileAtomic } from './json.mjs'
8
9
 
9
10
  export async function lintKb(kbRoot, { fix = false } = {}) {
10
11
  const p = kbPaths(kbRoot)
@@ -126,6 +127,8 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
126
127
 
127
128
  if (fix) { buildIndex(kbRoot); autoFixed.push('index-rebuilt') }
128
129
  const report = { autoFixed, mechanical, semantic }
129
- fs.writeFileSync(path.join(kbRoot, '.lint-report.json'), JSON.stringify(report, null, 2) + '\n')
130
+ // Atomic write for uniformity with the other JSON state files (temp+rename). The
131
+ // report has no in-process reader today, so this is preventive, not a live torn-read.
132
+ writeFileAtomic(path.join(kbRoot, '.lint-report.json'), JSON.stringify(report, null, 2) + '\n')
130
133
  return report
131
134
  }
package/src/manifest.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import { kbPaths } from './paths.mjs'
3
- import { readJsonFile } from './json.mjs'
3
+ import { readJsonFile, writeFileAtomic } from './json.mjs'
4
4
 
5
5
  export function loadManifest(kbRoot) {
6
6
  const p = kbPaths(kbRoot)
@@ -13,11 +13,9 @@ export function loadManifest(kbRoot) {
13
13
 
14
14
  export function saveManifest(kbRoot, manifest) {
15
15
  const p = kbPaths(kbRoot)
16
- // Atomic write: manifest is non-derived state (hash->raw map). A crash mid-write
17
- // must not leave a truncated/corrupt file. Write a temp sibling, then rename.
18
- const tmp = `${p.manifest}.tmp`
19
- fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n')
20
- fs.renameSync(tmp, p.manifest)
16
+ // Atomic write (shared writeFileAtomic): manifest is non-derived state (hash->raw
17
+ // map) and a crash or concurrent reader must never see a truncated/corrupt file.
18
+ writeFileAtomic(p.manifest, JSON.stringify(manifest, null, 2) + '\n')
21
19
  }
22
20
 
23
21
  export function diffManifest(manifest, entries) {
package/src/scanner.mjs CHANGED
@@ -6,6 +6,7 @@ import { SUPPORTED_EXTS } from './convert.mjs'
6
6
  import { sha256File, minhashSignature, jaccardEstimate } from './hashing.mjs'
7
7
  import { loadManifest, diffManifest } from './manifest.mjs'
8
8
  import { listWikiPages, isInvalidated } from './pages.mjs'
9
+ import { writeFileAtomic } from './json.mjs'
9
10
 
10
11
  const TEXT_EXTS = ['.md', '.markdown', '.txt', '.html', '.htm']
11
12
  const NEAR_DUP_THRESHOLD = 0.85
@@ -169,6 +170,9 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
169
170
  batches,
170
171
  estimate,
171
172
  }
172
- if (persist) fs.writeFileSync(kbPaths(kbRoot).scanPlan, JSON.stringify(report, null, 2) + '\n')
173
+ // Atomic write: `convert` reads .scan-plan.json (runConvertPlan) as a separate
174
+ // process, so a direct writeFileSync (truncate-then-write) could hand it a torn
175
+ // file. Same torn-read class as buildIndex's derived stores.
176
+ if (persist) writeFileAtomic(kbPaths(kbRoot).scanPlan, JSON.stringify(report, null, 2) + '\n')
173
177
  return report
174
178
  }
package/src/templates.mjs CHANGED
@@ -17,12 +17,28 @@ export const DEFAULT_CONFIG = {
17
17
  relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
18
18
  }
19
19
 
20
+ // Keys whose default is a number are consumed as scalars — an Array(n) length
21
+ // (bm25TitleWeight), a loop stride (batchSize), or a threshold comparison. Because
22
+ // wiki.config.json is shallow-merged as-is (any JSON type per key), a scalar there
23
+ // can be a string/object/NaN/0/negative, which crashes (`Array(NaN)` RangeError),
24
+ // hangs (`i += 0` never advances the batch loop), or silently disables a gate.
25
+ const NUMERIC_KEYS = Object.keys(DEFAULT_CONFIG).filter((k) => typeof DEFAULT_CONFIG[k] === 'number')
26
+
20
27
  // Single reader for wiki.config.json (defaults merged) — every consumer used
21
28
  // to inline this parse, each with its own bare-SyntaxError failure mode.
22
29
  export function loadKbConfig(kbRoot) {
23
30
  const p = kbPaths(kbRoot)
24
- if (!fs.existsSync(p.config)) return DEFAULT_CONFIG
25
- return { ...DEFAULT_CONFIG, ...readJsonFile(p.config) }
31
+ if (!fs.existsSync(p.config)) return { ...DEFAULT_CONFIG }
32
+ const merged = { ...DEFAULT_CONFIG, ...readJsonFile(p.config) }
33
+ // Coerce every numeric key back to a usable finite integer >= 1; fall back to the
34
+ // default on anything a user could type that isn't one. Every DEFAULT is itself a
35
+ // positive integer, so a legitimate override is untouched — only malformed values
36
+ // (which would crash/hang/mis-gate a consumer) are normalized away.
37
+ for (const k of NUMERIC_KEYS) {
38
+ const n = Math.trunc(Number(merged[k]))
39
+ merged[k] = Number.isFinite(n) && n >= 1 ? n : DEFAULT_CONFIG[k]
40
+ }
41
+ return merged
26
42
  }
27
43
 
28
44
  export function agentsMdTemplate(cfg) {
package/src/vector.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 { readJsonFile } from './json.mjs'
4
+ import { readJsonFile, writeFileAtomic } from './json.mjs'
5
5
  import { asList } from './pages.mjs'
6
6
 
7
7
  export function normalize(vec) {
@@ -37,13 +37,10 @@ export function loadVectorStore(kbRoot) {
37
37
  export function saveVectorStore(kbRoot, store) {
38
38
  const rounded = { ...store, pages: Object.fromEntries(Object.entries(store.pages).map(([id, e]) =>
39
39
  [id, { hash: e.hash, vec: e.vec.map(x => Number(x.toFixed(5))) }])) }
40
- // Atomic write: the per-batch flush in embedKb re-saves repeatedly; a crash
41
- // mid-write must not leave a truncated store (loadVectorStore fails open, but
42
- // temp+rename avoids the corruption entirely).
43
- const f = vectorStorePath(kbRoot)
44
- const tmp = `${f}.tmp`
45
- fs.writeFileSync(tmp, JSON.stringify(rounded) + '\n')
46
- fs.renameSync(tmp, f)
40
+ // Atomic write (shared writeFileAtomic): the per-batch flush in embedKb re-saves
41
+ // repeatedly; a crash or concurrent reader must never see a truncated store
42
+ // (loadVectorStore fails open, but temp+rename avoids the corruption entirely).
43
+ writeFileAtomic(vectorStorePath(kbRoot), JSON.stringify(rounded) + '\n')
47
44
  }
48
45
 
49
46
  // queryVec must already be normalized; stored vecs are normalized at save time,