@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.
- package/CHANGELOG.md +37 -0
- package/README.md +88 -0
- package/bin/llm-wiki.mjs +123 -0
- package/package.json +36 -0
- package/skills/wiki-build/SKILL.md +34 -0
- package/skills/wiki-connect/SKILL.md +14 -0
- package/skills/wiki-distill/SKILL.md +32 -0
- package/skills/wiki-ingest/SKILL.md +17 -0
- package/skills/wiki-lint/SKILL.md +26 -0
- package/skills/wiki-query/SKILL.md +17 -0
- package/src/ask.mjs +42 -0
- package/src/bm25.mjs +42 -0
- package/src/connect.mjs +44 -0
- package/src/convert-run.mjs +41 -0
- package/src/convert.mjs +62 -0
- package/src/export.mjs +176 -0
- package/src/frontmatter.mjs +16 -0
- package/src/hashing.mjs +35 -0
- package/src/indexer.mjs +87 -0
- package/src/init.mjs +43 -0
- package/src/lint.mjs +92 -0
- package/src/llm-config.mjs +65 -0
- package/src/manifest.mjs +27 -0
- package/src/pages.mjs +42 -0
- package/src/paths.mjs +25 -0
- package/src/scanner.mjs +103 -0
- package/src/status.mjs +61 -0
- package/src/templates.mjs +80 -0
package/src/convert.mjs
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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
|
+
}
|
package/src/hashing.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/indexer.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/manifest.mjs
ADDED
|
@@ -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
|
+
}
|