@sdsrs/llm-wiki 0.7.0 → 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 CHANGED
@@ -1,5 +1,32 @@
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
+
3
30
  ## 0.7.0 (2026-07-11)
4
31
 
5
32
  Two retrieval-default changes (hence a minor bump). Both are opt-out via
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/llm-wiki",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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
@@ -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 } from './pages.mjs'
5
+ import { listWikiPages, isInvalidated, asList } from './pages.mjs'
6
6
  import { buildBm25Index, searchBm25 } from './bm25.mjs'
7
7
  import { worstCaseTokens } from './scanner.mjs'
8
8
  import { loadLlmConfig, makeTransport } from './llm-config.mjs'
@@ -19,7 +19,7 @@ export function retrievePages(kbRoot, question, k = 6) {
19
19
  const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
20
20
  const idx = buildBm25Index(pages.map(p => ({
21
21
  id: p.relPath,
22
- text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, (p.data.tags ?? []).join(' '), p.body].join('\n'),
22
+ text: [Array(w).fill(p.data.title ?? '').join('\n'), p.data.description, asList(p.data.tags).join(' '), p.body].join('\n'),
23
23
  })))
24
24
  return searchBm25(idx, question, k).map(h => ({ relPath: h.id, score: h.score }))
25
25
  }
@@ -84,11 +84,19 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
84
84
  }
85
85
 
86
86
  export async function chatCompletion(cfg, t, messages) {
87
- const res = await fetchWithRetry(t.fetchImpl, `${cfg.baseURL.replace(/\/$/, '')}/chat/completions`, {
88
- method: 'POST',
89
- headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
90
- body: JSON.stringify({ model: cfg.model, messages }),
91
- }, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
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
+ }
92
100
  if (!res.ok) {
93
101
  let body = ''
94
102
  try { body = (await res.text()).slice(0, 200) } catch { /* body unreadable; status alone */ }
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 res = await fetchWithRetry(t.fetchImpl, `${cfg.baseURL.replace(/\/$/, '')}/embeddings`, {
49
- method: 'POST',
50
- headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
51
- body: JSON.stringify({ model: cfg.embeddingModel, input: texts }),
52
- }, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
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 ?? []) edges.push({ source: id, target: String(src), type: 'source', confidence: 'extracted' })
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
- if (pg.error) continue
94
- for (const tag of pg.data.tags ?? []) {
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/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/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 ?? []).join(' '), pg.body].join('\n')
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) {