@sdsrs/llm-wiki 0.2.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.
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export const SUPPORTED_EXTS = ['.md', '.markdown', '.txt', '.html', '.htm', '.pdf', '.docx']
5
+
6
+ export function slugify(name) {
7
+ return name.toLowerCase()
8
+ .replace(/\.[a-z0-9]+$/i, '')
9
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
10
+ .replace(/^-+|-+$/g, '')
11
+ }
12
+
13
+ function titleFrom(markdown, fallback) {
14
+ const m = markdown.match(/^#\s+(.+)$/m)
15
+ return m ? m[1].trim() : fallback
16
+ }
17
+
18
+ export async function convertFile(srcPath) {
19
+ const ext = path.extname(srcPath).toLowerCase()
20
+ const base = path.basename(srcPath)
21
+ const warnings = []
22
+ try {
23
+ if (ext === '.md' || ext === '.markdown') {
24
+ const md = fs.readFileSync(srcPath, 'utf8')
25
+ return { markdown: md, title: titleFrom(md, base), warnings }
26
+ }
27
+ if (ext === '.txt') {
28
+ const md = fs.readFileSync(srcPath, 'utf8')
29
+ return { markdown: md, title: titleFrom(md, base), warnings }
30
+ }
31
+ if (ext === '.html' || ext === '.htm') {
32
+ const { JSDOM } = await import('jsdom')
33
+ const { Readability } = await import('@mozilla/readability')
34
+ const TurndownService = (await import('turndown')).default
35
+ const html = fs.readFileSync(srcPath, 'utf8')
36
+ const dom = new JSDOM(html, { url: 'https://local.invalid/' })
37
+ const article = new Readability(dom.window.document).parse()
38
+ if (!article?.content) {
39
+ warnings.push('readability found no main content; skipped')
40
+ return { markdown: null, title: base, warnings }
41
+ }
42
+ const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' })
43
+ const md = td.turndown(article.content)
44
+ return { markdown: `# ${article.title || base}\n\n${md}`, title: article.title || base, warnings }
45
+ }
46
+ if (ext === '.pdf') {
47
+ const pdfParse = (await import('pdf-parse/lib/pdf-parse.js')).default
48
+ const data = await pdfParse(fs.readFileSync(srcPath))
49
+ return { markdown: data.text, title: titleFrom(data.text, base), warnings }
50
+ }
51
+ if (ext === '.docx') {
52
+ const mammoth = await import('mammoth')
53
+ const { value } = await mammoth.extractRawText({ path: srcPath })
54
+ return { markdown: value, title: titleFrom(value, base), warnings }
55
+ }
56
+ warnings.push(`unsupported extension: ${ext}`)
57
+ return { markdown: null, title: base, warnings }
58
+ } catch (err) {
59
+ warnings.push(`convert failed: ${err.message}`)
60
+ return { markdown: null, title: base, warnings }
61
+ }
62
+ }
package/src/export.mjs ADDED
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { kbPaths } from './paths.mjs'
4
+
5
+ const xmlEscape = (s) => String(s)
6
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
7
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
8
+
9
+ const cyEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
10
+
11
+ // graph.json only lists wiki pages as nodes, but source-type edges point at raw/
12
+ // paths. Exports need every edge endpoint to exist, so synthesize raw nodes.
13
+ export function loadGraph(kbRoot) {
14
+ const p = kbPaths(kbRoot)
15
+ if (!fs.existsSync(p.graphJson)) throw new Error('wiki/graph.json not found — run `llm-wiki index` first.')
16
+ const graph = JSON.parse(fs.readFileSync(p.graphJson, 'utf8'))
17
+ const ids = new Set(graph.nodes.map(n => n.id))
18
+ for (const e of graph.edges) {
19
+ for (const end of [e.source, e.target]) {
20
+ if (!ids.has(end)) {
21
+ ids.add(end)
22
+ graph.nodes.push({ id: end, type: 'raw', title: path.basename(end) })
23
+ }
24
+ }
25
+ }
26
+ return graph
27
+ }
28
+
29
+ export function toGraphML(graph) {
30
+ const lines = [
31
+ '<?xml version="1.0" encoding="UTF-8"?>',
32
+ '<graphml xmlns="http://graphml.graphdrawing.org/xmlns">',
33
+ ' <key id="d0" for="node" attr.name="type" attr.type="string"/>',
34
+ ' <key id="d1" for="node" attr.name="title" attr.type="string"/>',
35
+ ' <key id="d2" for="node" attr.name="status" attr.type="string"/>',
36
+ ' <key id="d3" for="edge" attr.name="type" attr.type="string"/>',
37
+ ' <graph id="llm_wiki" edgedefault="directed">',
38
+ ]
39
+ for (const n of graph.nodes) {
40
+ lines.push(` <node id="${xmlEscape(n.id)}">`)
41
+ lines.push(` <data key="d0">${xmlEscape(n.type ?? '')}</data>`)
42
+ lines.push(` <data key="d1">${xmlEscape(n.title ?? '')}</data>`)
43
+ if (n.status) lines.push(` <data key="d2">${xmlEscape(n.status)}</data>`)
44
+ lines.push(' </node>')
45
+ }
46
+ graph.edges.forEach((e, i) => {
47
+ lines.push(` <edge id="e${i}" source="${xmlEscape(e.source)}" target="${xmlEscape(e.target)}"><data key="d3">${xmlEscape(e.type ?? '')}</data></edge>`)
48
+ })
49
+ lines.push(' </graph>', '</graphml>')
50
+ return lines.join('\n') + '\n'
51
+ }
52
+
53
+ export function toCypher(graph) {
54
+ const label = (t) => ({ source: 'Source', entity: 'Entity', concept: 'Concept', comparison: 'Comparison', raw: 'Raw' })[t] ?? 'Page'
55
+ const lines = graph.nodes.map(n =>
56
+ `MERGE (n:${label(n.type)} {id: '${cyEscape(n.id)}'}) SET n.title = '${cyEscape(n.title ?? '')}'${n.status ? `, n.status = '${cyEscape(n.status)}'` : ''};`)
57
+ for (const e of graph.edges) {
58
+ const rel = String(e.type ?? 'link').replace(/[^a-zA-Z_]/g, '_').toUpperCase()
59
+ lines.push(`MATCH (a {id: '${cyEscape(e.source)}'}), (b {id: '${cyEscape(e.target)}'}) MERGE (a)-[:${rel}]->(b);`)
60
+ }
61
+ return lines.join('\n') + '\n'
62
+ }
63
+
64
+ export function toHtml(graph) {
65
+ // <-escape keeps any "</script>" inside titles from terminating the script block.
66
+ const data = JSON.stringify({ nodes: graph.nodes, edges: graph.edges }).replace(/</g, '\\u003c')
67
+ return `<!doctype html>
68
+ <html>
69
+ <head>
70
+ <meta charset="utf-8">
71
+ <title>llm_wiki graph</title>
72
+ <style>
73
+ html, body { margin: 0; height: 100%; background: #111; color: #ddd; font: 12px system-ui, sans-serif; }
74
+ #legend { position: fixed; top: 8px; left: 8px; background: #000a; padding: 6px 10px; border-radius: 6px; }
75
+ #legend span { margin-right: 10px; }
76
+ canvas { display: block; }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <div id="legend"></div>
81
+ <canvas id="c"></canvas>
82
+ <script>
83
+ const GRAPH = ${data}
84
+ const COLORS = { source: '#4e9af1', entity: '#5fbf77', concept: '#c98bdb', comparison: '#e0b25c', raw: '#777' }
85
+ const canvas = document.getElementById('c')
86
+ const ctx = canvas.getContext('2d')
87
+ let W, H
88
+ function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight }
89
+ resize(); addEventListener('resize', resize)
90
+
91
+ document.getElementById('legend').innerHTML = Object.entries(COLORS)
92
+ .map(([t, c]) => '<span><b style="color:' + c + '">●</b> ' + t + '</span>').join('')
93
+ + '<span>◌ invalidated</span><span>drag: pan · wheel: zoom</span>'
94
+
95
+ const nodes = GRAPH.nodes.map((n, i) => ({
96
+ ...n,
97
+ x: Math.cos(i * 2.399963) * (60 + 14 * Math.sqrt(i)),
98
+ y: Math.sin(i * 2.399963) * (60 + 14 * Math.sqrt(i)),
99
+ vx: 0, vy: 0,
100
+ }))
101
+ const byId = new Map(nodes.map(n => [n.id, n]))
102
+ const edges = GRAPH.edges.map(e => ({ ...e, a: byId.get(e.source), b: byId.get(e.target) })).filter(e => e.a && e.b)
103
+
104
+ let scale = Math.min(2, 500 / (60 + 14 * Math.sqrt(nodes.length))), ox = 0, oy = 0
105
+ let dragging = false, px = 0, py = 0, hover = null
106
+ canvas.onmousedown = (e) => { dragging = true; px = e.clientX; py = e.clientY }
107
+ onmouseup = () => { dragging = false }
108
+ onmousemove = (e) => {
109
+ if (dragging) { ox += e.clientX - px; oy += e.clientY - py; px = e.clientX; py = e.clientY; return }
110
+ const mx = (e.clientX - W / 2 - ox) / scale, my = (e.clientY - H / 2 - oy) / scale
111
+ hover = null
112
+ for (const n of nodes) if ((n.x - mx) ** 2 + (n.y - my) ** 2 < 100) { hover = n; break }
113
+ }
114
+ canvas.onwheel = (e) => { e.preventDefault(); scale *= e.deltaY < 0 ? 1.1 : 0.9 }
115
+
116
+ let ticks = 0
117
+ function step() {
118
+ if (ticks++ < 300) {
119
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
120
+ const a = nodes[i], b = nodes[j]
121
+ let dx = a.x - b.x, dy = a.y - b.y
122
+ const d2 = dx * dx + dy * dy || 1
123
+ const f = Math.min(1200 / d2, 4)
124
+ dx *= f / Math.sqrt(d2); dy *= f / Math.sqrt(d2)
125
+ a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy
126
+ }
127
+ for (const e of edges) {
128
+ const dx = e.b.x - e.a.x, dy = e.b.y - e.a.y
129
+ const d = Math.sqrt(dx * dx + dy * dy) || 1
130
+ const f = (d - 60) * 0.01
131
+ e.a.vx += dx / d * f; e.a.vy += dy / d * f
132
+ e.b.vx -= dx / d * f; e.b.vy -= dy / d * f
133
+ }
134
+ for (const n of nodes) { n.vx *= 0.85; n.vy *= 0.85; n.x += n.vx; n.y += n.vy }
135
+ }
136
+ ctx.clearRect(0, 0, W, H)
137
+ ctx.save()
138
+ ctx.translate(W / 2 + ox, H / 2 + oy)
139
+ ctx.scale(scale, scale)
140
+ ctx.strokeStyle = '#444'
141
+ for (const e of edges) {
142
+ ctx.setLineDash(e.type === 'superseded_by' ? [4, 3] : [])
143
+ ctx.beginPath(); ctx.moveTo(e.a.x, e.a.y); ctx.lineTo(e.b.x, e.b.y); ctx.stroke()
144
+ }
145
+ ctx.setLineDash([])
146
+ for (const n of nodes) {
147
+ ctx.fillStyle = COLORS[n.type] ?? '#aaa'
148
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.type === 'raw' ? 3 : 6, 0, 7); ctx.fill()
149
+ if (n.status === 'invalidated') {
150
+ ctx.strokeStyle = '#e05c5c'; ctx.setLineDash([2, 2])
151
+ ctx.beginPath(); ctx.arc(n.x, n.y, 9, 0, 7); ctx.stroke(); ctx.setLineDash([])
152
+ }
153
+ }
154
+ ctx.fillStyle = '#ddd'
155
+ if (scale > 0.8) for (const n of nodes) if (n.type !== 'raw') ctx.fillText(n.title ?? n.id, n.x + 8, n.y + 3)
156
+ if (hover) { ctx.font = 'bold 12px system-ui'; ctx.fillStyle = '#fff'; ctx.fillText(hover.id + (hover.status ? ' [' + hover.status + ']' : ''), hover.x + 8, hover.y - 8); ctx.font = '12px system-ui' }
157
+ ctx.restore()
158
+ requestAnimationFrame(step)
159
+ }
160
+ step()
161
+ </script>
162
+ </body>
163
+ </html>
164
+ `
165
+ }
166
+
167
+ const RENDERERS = { graphml: toGraphML, cypher: toCypher, html: toHtml }
168
+
169
+ export function exportGraph(kbRoot, { format, out } = {}) {
170
+ const render = RENDERERS[format]
171
+ if (!render) throw new Error(`unknown format: ${format} (expected ${Object.keys(RENDERERS).join(' | ')})`)
172
+ const graph = loadGraph(kbRoot)
173
+ const outPath = out ?? path.join(kbRoot, `graph.${format === 'graphml' ? 'graphml' : format === 'cypher' ? 'cypher' : 'html'}`)
174
+ fs.writeFileSync(outPath, render(graph))
175
+ return { out: outPath, nodeCount: graph.nodes.length, edgeCount: graph.edges.length }
176
+ }
@@ -0,0 +1,16 @@
1
+ import YAML from 'yaml'
2
+
3
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
4
+
5
+ export function parseFrontmatter(text) {
6
+ const m = text.match(FM_RE)
7
+ if (!m) return null
8
+ let data
9
+ try { data = YAML.parse(m[1]) } catch { return { error: 'invalid-yaml' } }
10
+ if (data === null || typeof data !== 'object') return { error: 'invalid-yaml' }
11
+ return { data, body: text.slice(m[0].length) }
12
+ }
13
+
14
+ export function serializeFrontmatter(data, body) {
15
+ return `---\n${YAML.stringify(data)}---\n\n${body.replace(/^\n+/, '')}`
16
+ }
@@ -0,0 +1,35 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+
4
+ export const sha256Text = (t) => crypto.createHash('sha256').update(t).digest('hex')
5
+ export const sha256File = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')
6
+
7
+ function fnv1a(str) {
8
+ let h = 0x811c9dc5
9
+ for (let i = 0; i < str.length; i++) {
10
+ h ^= str.charCodeAt(i)
11
+ h = Math.imul(h, 0x01000193) >>> 0
12
+ }
13
+ return h
14
+ }
15
+
16
+ export function minhashSignature(text, perms = 32) {
17
+ const norm = text.toLowerCase().replace(/\s+/g, ' ')
18
+ const shingles = new Set()
19
+ for (let i = 0; i <= norm.length - 5; i++) shingles.add(norm.slice(i, i + 5))
20
+ if (shingles.size === 0) return new Array(perms).fill(0)
21
+ const sig = new Array(perms).fill(Infinity)
22
+ for (const s of shingles) {
23
+ for (let p = 0; p < perms; p++) {
24
+ const h = fnv1a(p + ' ' + s)
25
+ if (h < sig[p]) sig[p] = h
26
+ }
27
+ }
28
+ return sig
29
+ }
30
+
31
+ export function jaccardEstimate(a, b) {
32
+ let eq = 0
33
+ for (let i = 0; i < a.length; i++) if (a[i] === b[i]) eq++
34
+ return eq / a.length
35
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { kbPaths } from './paths.mjs'
4
+ import { DEFAULT_CONFIG } from './templates.mjs'
5
+ import { listWikiPages, isInvalidated } from './pages.mjs'
6
+
7
+ const WIKILINK_RE = /\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]/g
8
+
9
+ export function extractWikilinks(body) {
10
+ const out = []
11
+ for (const m of body.matchAll(WIKILINK_RE)) {
12
+ const target = m[1].trim().replace(/\.md$/, '')
13
+ if (!out.includes(target)) out.push(target)
14
+ }
15
+ return out
16
+ }
17
+
18
+ const SECTION_TITLES = { source: 'Sources', entity: 'Entities', concept: 'Concepts', comparison: 'Comparisons', other: 'Other' }
19
+
20
+ export function buildIndex(kbRoot) {
21
+ const p = kbPaths(kbRoot)
22
+ const cfg = fs.existsSync(p.config) ? { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(p.config, 'utf8')) } : DEFAULT_CONFIG
23
+ const pages = listWikiPages(kbRoot).filter(pg => !pg.error)
24
+
25
+ // preserve pending section
26
+ let pending = '## Pending concepts\n'
27
+ if (fs.existsSync(p.indexMd)) {
28
+ const m = fs.readFileSync(p.indexMd, 'utf8').match(/## Pending concepts[\s\S]*$/)
29
+ if (m) pending = m[0].trimEnd() + '\n'
30
+ }
31
+
32
+ // `other` collects unknown types so index.md and graph.json agree on a page's bucket
33
+ // (graph nodes still carry the raw type). Rendered as a final `## Other` section, only when non-empty.
34
+ const byType = { source: [], entity: [], concept: [], comparison: [], other: [] }
35
+ for (const pg of pages) (byType[pg.data.type] ?? byType.other).push(pg)
36
+ const line = (pg) => {
37
+ const base = `- [[${pg.relPath.replace(/\.md$/, '')}]] — ${pg.data.description ?? ''}`
38
+ if (!isInvalidated(pg)) return base
39
+ return pg.data.superseded_by
40
+ ? `${base} ⚠ invalidated, superseded by [[${pg.data.superseded_by}]]`
41
+ : `${base} ⚠ invalidated`
42
+ }
43
+
44
+ let indexBody = '# Index\n\n> Auto-generated by `llm-wiki index`. Only the "Pending concepts" section may be edited by the LLM.\n'
45
+ const topicsSplit = pages.length > cfg.indexSplitAt
46
+ if (topicsSplit) {
47
+ fs.mkdirSync(p.topics, { recursive: true })
48
+ for (const [type, list] of Object.entries(byType)) {
49
+ if (!list.length) continue
50
+ fs.writeFileSync(path.join(p.topics, `${type}.md`), `# ${SECTION_TITLES[type]}\n\n${list.map(line).join('\n')}\n`)
51
+ indexBody += `\n## ${SECTION_TITLES[type]}\nSee [[topics/${type}]] (${list.length} pages)\n`
52
+ }
53
+ } else {
54
+ for (const [type, list] of Object.entries(byType)) {
55
+ if (type === 'other' && !list.length) continue
56
+ indexBody += `\n## ${SECTION_TITLES[type]}\n${list.map(line).join('\n')}\n`
57
+ }
58
+ }
59
+ fs.writeFileSync(p.indexMd, `${indexBody}\n${pending}`)
60
+
61
+ const ids = new Set(pages.map(pg => pg.relPath.replace(/\.md$/, '')))
62
+ const nodes = pages.map(pg => ({
63
+ id: pg.relPath.replace(/\.md$/, ''),
64
+ type: pg.data.type,
65
+ title: pg.data.title,
66
+ ...(isInvalidated(pg) ? { status: 'invalidated' } : {}),
67
+ }))
68
+ const edges = []
69
+ for (const pg of pages) {
70
+ const id = pg.relPath.replace(/\.md$/, '')
71
+ for (const target of extractWikilinks(pg.body)) {
72
+ if (ids.has(target)) edges.push({ source: id, target, type: 'wikilink' })
73
+ }
74
+ for (const src of pg.data.sources ?? []) edges.push({ source: id, target: String(src), type: 'source' })
75
+ if (pg.data.superseded_by && ids.has(String(pg.data.superseded_by))) {
76
+ edges.push({ source: id, target: String(pg.data.superseded_by), type: 'superseded_by' })
77
+ }
78
+ }
79
+ fs.writeFileSync(p.graphJson, JSON.stringify({ nodes, edges }, null, 2) + '\n')
80
+
81
+ const kbName = path.basename(path.resolve(kbRoot))
82
+ const llms = [`# ${kbName}`, '', `> llm_wiki knowledge base. Read wiki/index.md first, then open full pages.`, '', '## Pages',
83
+ ...pages.filter(pg => !isInvalidated(pg)).map(pg => `- [${pg.data.title}](wiki/${pg.relPath}): ${pg.data.description ?? ''}`)].join('\n') + '\n'
84
+ fs.writeFileSync(p.llmsTxt, llms)
85
+
86
+ return { pageCount: pages.length, topicsSplit }
87
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { kbPaths } from './paths.mjs'
4
+ import { DEFAULT_CONFIG, agentsMdTemplate, readmeTemplate } from './templates.mjs'
5
+
6
+ const INDEX_SKELETON = `# Index
7
+
8
+ > Auto-generated by \`llm-wiki index\`. Only the "Pending concepts" section may be edited by the LLM.
9
+
10
+ ## Sources
11
+
12
+ ## Entities
13
+
14
+ ## Concepts
15
+
16
+ ## Comparisons
17
+
18
+ ## Pending concepts
19
+ `
20
+
21
+ export function initKb(root) {
22
+ const p = kbPaths(root, DEFAULT_CONFIG)
23
+ const created = []
24
+ const skipped = []
25
+ const dir = (d) => {
26
+ if (fs.existsSync(d)) { skipped.push(d) } else { fs.mkdirSync(d, { recursive: true }); created.push(d) }
27
+ }
28
+ const file = (f, content) => {
29
+ if (fs.existsSync(f)) { skipped.push(f) } else { fs.writeFileSync(f, content); created.push(f) }
30
+ }
31
+ dir(root)
32
+ dir(p.raw)
33
+ dir(p.sources); dir(p.entities); dir(p.concepts); dir(p.comparisons)
34
+ const { rawDir, schemaFile, linkStyle, ...emittedConfig } = DEFAULT_CONFIG
35
+ file(p.config, JSON.stringify(emittedConfig, null, 2) + '\n')
36
+ file(p.schemaFile, agentsMdTemplate(DEFAULT_CONFIG))
37
+ file(p.readme, readmeTemplate(path.basename(path.resolve(root))))
38
+ file(p.manifest, JSON.stringify({ files: {} }, null, 2) + '\n')
39
+ file(p.indexMd, INDEX_SKELETON)
40
+ file(p.logMd, '# Log\n')
41
+ file(p.hotMd, '# Hot\n\n(recent activity snapshot, maintained by the LLM, ~500 chars)\n')
42
+ return { created, skipped }
43
+ }
package/src/lint.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { kbPaths } from './paths.mjs'
4
+ import { DEFAULT_CONFIG } from './templates.mjs'
5
+ import { listWikiPages, validatePage, isInvalidated, PAGE_STATUSES } from './pages.mjs'
6
+ import { extractWikilinks, buildIndex } from './indexer.mjs'
7
+ import { loadManifest } from './manifest.mjs'
8
+
9
+ export async function lintKb(kbRoot, { fix = false } = {}) {
10
+ const p = kbPaths(kbRoot)
11
+ const cfg = fs.existsSync(p.config) ? { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(p.config, 'utf8')) } : DEFAULT_CONFIG
12
+ const pages = listWikiPages(kbRoot)
13
+ const mechanical = []
14
+ const semantic = []
15
+ const autoFixed = []
16
+ const ids = new Set(pages.filter(pg => !pg.error).map(pg => pg.relPath.replace(/\.md$/, '')))
17
+ const incoming = new Map()
18
+
19
+ for (const pg of pages) {
20
+ if (pg.error) { mechanical.push({ rule: 'invalid-frontmatter', path: pg.relPath, detail: pg.error }); continue }
21
+ for (const issue of validatePage(pg)) mechanical.push({ rule: 'missing-field', path: pg.relPath, detail: issue })
22
+ for (const target of extractWikilinks(pg.body)) {
23
+ if (target.startsWith('raw/')) {
24
+ if (!fs.existsSync(path.join(kbRoot, target)) && !fs.existsSync(path.join(kbRoot, target + '.md'))) mechanical.push({ rule: 'broken-raw-link', path: pg.relPath, detail: `-> ${target}` })
25
+ continue
26
+ }
27
+ if (!ids.has(target)) mechanical.push({ rule: 'broken-wikilink', path: pg.relPath, detail: `-> ${target}` })
28
+ else incoming.set(target, (incoming.get(target) ?? 0) + 1)
29
+ }
30
+ for (const src of pg.data.sources ?? []) {
31
+ if (!fs.existsSync(path.join(kbRoot, String(src)))) mechanical.push({ rule: 'missing-raw-source', path: pg.relPath, detail: String(src) })
32
+ }
33
+ if (pg.data.status !== undefined && !PAGE_STATUSES.includes(pg.data.status)) {
34
+ mechanical.push({ rule: 'invalid-status', path: pg.relPath, detail: `status "${pg.data.status}" (expected ${PAGE_STATUSES.join(' | ')})` })
35
+ }
36
+ if (pg.data.superseded_by !== undefined && !ids.has(String(pg.data.superseded_by))) {
37
+ mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
38
+ }
39
+ }
40
+ for (const pg of pages) {
41
+ if (pg.error) continue
42
+ const id = pg.relPath.replace(/\.md$/, '')
43
+ if (pg.data.type !== 'source' && pg.data.type !== 'comparison' && !isInvalidated(pg) && !incoming.has(id)) mechanical.push({ rule: 'orphan-page', path: pg.relPath, detail: 'no incoming wikilinks' })
44
+ }
45
+
46
+ if (fs.existsSync(p.indexMd)) {
47
+ const pendingSection = fs.readFileSync(p.indexMd, 'utf8').match(/## Pending concepts([\s\S]*)$/)?.[1] ?? ''
48
+ for (const line of pendingSection.split('\n')) {
49
+ const m = line.match(/^-\s*(.+?)\s*—/)
50
+ if (!m) continue
51
+ const refs = (line.match(/\[\[/g) ?? []).length
52
+ if (refs >= cfg.conceptThreshold) semantic.push({ task: 'promote-concepts', detail: `${m[1]} (${refs} sources)` })
53
+ }
54
+ }
55
+ const byTag = new Map()
56
+ for (const pg of pages) {
57
+ if (pg.error) continue
58
+ for (const tag of pg.data.tags ?? []) {
59
+ if (!byTag.has(tag)) byTag.set(tag, [])
60
+ byTag.get(tag).push(pg.relPath)
61
+ }
62
+ }
63
+ for (const [tag, list] of byTag) {
64
+ // Only flag small shared-tag clusters: 2-5 pages are plausible contradiction candidates.
65
+ // Larger groups are navigation/topic tags (one tag on dozens of pages) — unactionable noise.
66
+ if (list.length >= 2 && list.length <= 5) semantic.push({ task: 'contradiction-scan', detail: `tag "${tag}": ${list.join(', ')}` })
67
+ }
68
+
69
+ // stale-scan: a raw file reconverted after a page's last update means the page may
70
+ // describe an outdated version of its source. Deterministic candidate generation;
71
+ // the LLM judges whether the page actually needs updating (STALE benchmark: LLMs
72
+ // detect staleness unaided at only ~55% accuracy — never rely on spontaneous detection).
73
+ const manifest = loadManifest(kbRoot)
74
+ const convertedAtByRaw = new Map()
75
+ for (const entry of Object.values(manifest.files)) {
76
+ if (entry.raw && entry.convertedAt) convertedAtByRaw.set(entry.raw, entry.convertedAt)
77
+ }
78
+ for (const pg of pages) {
79
+ if (pg.error || isInvalidated(pg)) continue
80
+ const updated = String(pg.data.updated ?? '')
81
+ if (!updated) continue
82
+ for (const src of pg.data.sources ?? []) {
83
+ const conv = convertedAtByRaw.get(String(src))
84
+ if (conv && conv > updated) semantic.push({ task: 'stale-scan', detail: `${pg.relPath}: ${src} reconverted ${conv}, page updated ${updated}` })
85
+ }
86
+ }
87
+
88
+ if (fix) { buildIndex(kbRoot); autoFixed.push('index-rebuilt') }
89
+ const report = { autoFixed, mechanical, semantic }
90
+ fs.writeFileSync(path.join(kbRoot, '.lint-report.json'), JSON.stringify(report, null, 2) + '\n')
91
+ return report
92
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { kbPaths } from './paths.mjs'
5
+
6
+ const BUILTIN = {
7
+ priority: ['openai', 'openrouter'],
8
+ providers: {
9
+ openai: { baseURL: 'https://api.openai.com/v1', apiKeyEnv: 'OPENAI_API_KEY', model: 'gpt-4o-mini' },
10
+ openrouter: { baseURL: 'https://openrouter.ai/api/v1', apiKeyEnv: 'OPENROUTER_API_KEY', model: 'anthropic/claude-sonnet-5' },
11
+ },
12
+ }
13
+
14
+ function resolveProviders(fileCfg) {
15
+ const { priority, providers } = fileCfg.providers ? fileCfg : BUILTIN
16
+ for (const name of priority ?? Object.keys(providers)) {
17
+ const prov = providers[name]
18
+ if (!prov) continue
19
+ const key = prov.apiKey ?? process.env[prov.apiKeyEnv]
20
+ if (key) return { baseURL: prov.baseURL, apiKey: key, model: prov.model }
21
+ }
22
+ return null
23
+ }
24
+
25
+ export function loadLlmConfig(kbRoot) {
26
+ const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
27
+ const globalFile = path.join(dir, 'config.json')
28
+ const fileCfg = fs.existsSync(globalFile) ? JSON.parse(fs.readFileSync(globalFile, 'utf8')) : {}
29
+ // flat form: explicit custom endpoint wins outright
30
+ let cfg = (fileCfg.baseURL && fileCfg.apiKey && fileCfg.model)
31
+ ? { baseURL: fileCfg.baseURL, apiKey: fileCfg.apiKey, model: fileCfg.model }
32
+ : resolveProviders(fileCfg)
33
+ const p = kbPaths(kbRoot)
34
+ if (fs.existsSync(p.config)) {
35
+ const kbCfg = JSON.parse(fs.readFileSync(p.config, 'utf8'))
36
+ if (kbCfg.llm) {
37
+ if (process.env.LLM_WIKI_ALLOW_KB_LLM_OVERRIDE === '1') {
38
+ // Opt-in: trust the KB fully (e.g. your own first-party KB).
39
+ cfg = { ...(cfg ?? {}), ...kbCfg.llm }
40
+ } else {
41
+ // Security: a third-party KB must not redirect requests (baseURL) or
42
+ // inject credentials — that would leak the user's env API key to an
43
+ // attacker endpoint. Honor only the model name by default.
44
+ const ignored = Object.keys(kbCfg.llm).filter(key => key !== 'model')
45
+ if (ignored.length) process.stderr.write(`warning: ignoring kb-level llm.${ignored.join(',')} override (security); set LLM_WIKI_ALLOW_KB_LLM_OVERRIDE=1 to allow\n`)
46
+ if (kbCfg.llm.model !== undefined) cfg = { ...(cfg ?? {}), model: kbCfg.llm.model }
47
+ }
48
+ }
49
+ }
50
+ if (cfg && process.env.LLM_WIKI_API_KEY) cfg.apiKey = process.env.LLM_WIKI_API_KEY
51
+ if (!cfg || !cfg.baseURL || !cfg.apiKey || !cfg.model) return null
52
+ return cfg
53
+ }
54
+
55
+ // Node's built-in fetch rejects the npm undici package's dispatcher
56
+ // ("invalid onRequestStart method": handler interface mismatch between
57
+ // undici 8.x and Node's bundled undici), so when a proxy is configured we
58
+ // must use undici's own fetch together with its EnvHttpProxyAgent.
59
+ export async function makeTransport() {
60
+ const hasProxy = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
61
+ .some(v => process.env[v])
62
+ if (!hasProxy) return { fetchImpl: globalThis.fetch, dispatcher: undefined }
63
+ const { fetch: proxyFetch, EnvHttpProxyAgent } = await import('undici')
64
+ return { fetchImpl: proxyFetch, dispatcher: new EnvHttpProxyAgent() }
65
+ }
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs'
2
+ import { kbPaths } from './paths.mjs'
3
+
4
+ export function loadManifest(kbRoot) {
5
+ const p = kbPaths(kbRoot)
6
+ if (!fs.existsSync(p.manifest)) return { files: {} }
7
+ return JSON.parse(fs.readFileSync(p.manifest, 'utf8'))
8
+ }
9
+
10
+ export function saveManifest(kbRoot, manifest) {
11
+ const p = kbPaths(kbRoot)
12
+ fs.writeFileSync(p.manifest, JSON.stringify(manifest, null, 2) + '\n')
13
+ }
14
+
15
+ export function diffManifest(manifest, entries) {
16
+ const added = [], changed = [], unchanged = []
17
+ const seen = new Set()
18
+ for (const e of entries) {
19
+ seen.add(e.rel)
20
+ const prev = manifest.files[e.rel]
21
+ if (!prev) added.push(e)
22
+ else if (prev.hash !== e.hash) changed.push(e)
23
+ else unchanged.push(e)
24
+ }
25
+ const removed = Object.keys(manifest.files).filter(rel => !seen.has(rel))
26
+ return { added, changed, removed, unchanged }
27
+ }
package/src/pages.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { kbPaths } from './paths.mjs'
4
+ import { parseFrontmatter } from './frontmatter.mjs'
5
+
6
+ const PAGE_DIRS = ['sources', 'entities', 'concepts', 'comparisons']
7
+ const REQUIRED = ['type', 'title', 'description', 'tags', 'created', 'updated']
8
+
9
+ export const PAGE_STATUSES = ['active', 'invalidated']
10
+
11
+ export function isInvalidated(page) {
12
+ return page.data?.status === 'invalidated'
13
+ }
14
+
15
+ export function listWikiPages(kbRoot) {
16
+ const p = kbPaths(kbRoot)
17
+ const pages = []
18
+ for (const dir of PAGE_DIRS) {
19
+ const abs = path.join(p.wiki, dir)
20
+ if (!fs.existsSync(abs)) continue
21
+ for (const f of fs.readdirSync(abs)) {
22
+ if (!f.endsWith('.md')) continue
23
+ const fileAbs = path.join(abs, f)
24
+ const text = fs.readFileSync(fileAbs, 'utf8')
25
+ const fm = parseFrontmatter(text)
26
+ if (fm === null) pages.push({ relPath: `${dir}/${f}`, abs: fileAbs, data: {}, body: text, error: 'missing-frontmatter' })
27
+ else if (fm.error) pages.push({ relPath: `${dir}/${f}`, abs: fileAbs, data: {}, body: text, error: fm.error })
28
+ else pages.push({ relPath: `${dir}/${f}`, abs: fileAbs, data: fm.data, body: fm.body })
29
+ }
30
+ }
31
+ return pages
32
+ }
33
+
34
+ export function validatePage(page) {
35
+ const issues = []
36
+ if (page.error) return [page.error]
37
+ for (const k of REQUIRED) {
38
+ if (page.data[k] === undefined || page.data[k] === null || page.data[k] === '') issues.push(`missing field: ${k}`)
39
+ }
40
+ if (!Array.isArray(page.data.sources)) issues.push('missing field: sources (evidence chain)')
41
+ return issues
42
+ }