@sdsrs/llm-wiki 0.6.4 → 0.6.6
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 +66 -0
- package/README.md +22 -4
- package/bin/llm-wiki.mjs +6 -2
- package/package.json +1 -1
- package/src/ask.mjs +8 -8
- package/src/embed.mjs +9 -5
- package/src/export.mjs +11 -4
- package/src/indexer.mjs +19 -2
- package/src/manifest.mjs +9 -2
- package/src/mcp.mjs +3 -3
- package/src/retry.mjs +29 -0
- package/src/scanner.mjs +21 -7
- package/src/templates.mjs +2 -1
- package/src/vector.mjs +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.6 (2026-07-11)
|
|
4
|
+
|
|
5
|
+
Audit remediation batch (roadmap M2 complete + M3/M4). No breaking changes, no KB
|
|
6
|
+
migration; new config keys default to prior behavior. Suite 185 → 208.
|
|
7
|
+
|
|
8
|
+
- **feat(cli): `llm-wiki --version`** prints the package version (was an unknown
|
|
9
|
+
option).
|
|
10
|
+
- **fix(net): LLM and embedding calls now time out and retry.** `chat/completions`
|
|
11
|
+
and `embeddings` requests go through a shared helper with an `AbortSignal.timeout`
|
|
12
|
+
ceiling (120s) and exponential-backoff retry on 429 / 5xx / network errors — a
|
|
13
|
+
hung or rate-limited provider no longer hangs the CLI or aborts a whole `embed`
|
|
14
|
+
run on one transient failure.
|
|
15
|
+
- **fix(embed): incremental durability.** `embed` now persists the vector store
|
|
16
|
+
after each batch, so a later batch failing keeps earlier batches' work (the
|
|
17
|
+
re-run reuses them by content hash — no repeated API cost). The store is written
|
|
18
|
+
atomically (temp + rename), as is `.manifest.json`.
|
|
19
|
+
- **fix(manifest): tolerate a hand-edited manifest** missing or mistyping the
|
|
20
|
+
`files` key instead of throwing deep in the diff.
|
|
21
|
+
- **fix(index): no line injection from frontmatter.** Newlines in a page's
|
|
22
|
+
title/description are collapsed before they are written into `index.md` /
|
|
23
|
+
`llms.txt`, so a crafted value can't inject headings or spoof wikilinks. Stale
|
|
24
|
+
`topics/*.md` are pruned when a type empties or the KB drops back below the split
|
|
25
|
+
threshold.
|
|
26
|
+
- **fix(export): escape newlines in Cypher** node titles so they can't split a
|
|
27
|
+
statement.
|
|
28
|
+
- **fix(docs): `@0` pin in the generated per-KB README**; corrected a fabricated
|
|
29
|
+
`supersedes` relation type in the main README to the real vocabulary (with a note
|
|
30
|
+
that `superseded_by` is a structural edge, not a `relations:` type); command
|
|
31
|
+
reference now lists `connect` / `install-skills` and documents `--version` /
|
|
32
|
+
`--follow-symlinks`; added an "Operational envelope" section (scale, no concurrent
|
|
33
|
+
writers, size cap, kana/hangul BM25 gap).
|
|
34
|
+
- Test coverage: `installSkills` + CLI-layer smokes for `index` / `lint` / `status`
|
|
35
|
+
/ `connect` / `install-skills` / `export` / `graph` and the `-k` / `--depth` /
|
|
36
|
+
`--top` guards; a timeout on the MCP stdio handshake test.
|
|
37
|
+
|
|
38
|
+
## 0.6.5 (2026-07-11)
|
|
39
|
+
|
|
40
|
+
Three security/robustness fixes from a full architecture-and-security audit
|
|
41
|
+
(M1, the release-blocking milestone). Suite 185 → 187.
|
|
42
|
+
|
|
43
|
+
**Behavior change (opt-out available):** `scan` now refuses a **symlinked source
|
|
44
|
+
file whose target resolves outside the source directory**, instead of silently
|
|
45
|
+
reading it into `raw/` and into a publishable wiki page. An attacker-supplied
|
|
46
|
+
corpus containing e.g. `notes.md -> ~/.llm-wiki/config.json` (or `~/.ssh/id_rsa`,
|
|
47
|
+
`/dev/zero`) could otherwise exfiltrate the victim's API key into a shared KB or
|
|
48
|
+
hang the process. Escaping links now appear in the scan report as
|
|
49
|
+
`skipped (symlink escapes source dir)`. **To restore the old follow-anywhere
|
|
50
|
+
behavior for a trusted, curated corpus, pass `--follow-symlinks`.** In-tree
|
|
51
|
+
symlinks are unaffected.
|
|
52
|
+
|
|
53
|
+
- **fix(scan): symlinked files escaping the source tree are refused** (audit
|
|
54
|
+
HIGH-1). `walk()` blocked symlinked *directories* (loop safety) but followed
|
|
55
|
+
symlinked *files* and read the link target; the content was copied into `raw/`
|
|
56
|
+
and `raw/_originals/`. `scan` now `realpath`-resolves each symlinked file and
|
|
57
|
+
skips any whose target is outside the source dir; `--follow-symlinks` opts back
|
|
58
|
+
in.
|
|
59
|
+
- **fix(scan): a file-size cap** (audit MEDIUM-1). Reads were unbounded — a
|
|
60
|
+
multi-GB (or `/dev/zero`-symlinked) file could OOM/hang the process. Files over
|
|
61
|
+
`maxFileBytes` (new `wiki.config.json` key, default 50 MB) are now skipped with
|
|
62
|
+
a clear reason rather than read whole.
|
|
63
|
+
- **fix(export): `--out` managed-layer guard is now a prefix check** (audit
|
|
64
|
+
MEDIUM-2). The guard rejected `--out <kb>/raw` and `--out <kb>/wiki` exactly
|
|
65
|
+
but let a *subdirectory* (`--out <kb>/raw/sub`) through, where the export's
|
|
66
|
+
re-run `rmSync` would then wipe part of the immutable `raw/` layer. It now
|
|
67
|
+
rejects the KB root and anything inside `raw/`/`wiki/` at any depth.
|
|
68
|
+
|
|
3
69
|
## 0.6.4 (2026-07-11)
|
|
4
70
|
|
|
5
71
|
Six fixes from a full-pipeline self-test pass (as a real user across every
|
package/README.md
CHANGED
|
@@ -65,8 +65,9 @@ npx @sdsrs/llm-wiki@0 ask "what did we decide about X?"
|
|
|
65
65
|
(28 probes), enabling vectors lifted Recall@5 from 0.73 to 0.97 — the gap
|
|
66
66
|
was almost entirely cross-language queries.
|
|
67
67
|
- **A real link graph, queried without an LLM** — `graph path | neighbors |
|
|
68
|
-
hubs` traverse wikilinks and typed relations (`implements`, `
|
|
69
|
-
|
|
68
|
+
hubs` traverse wikilinks and typed relations (`implements`, `contrasts_with`,
|
|
69
|
+
… — the `relationTypes` vocabulary; `superseded_by` is a separate structural
|
|
70
|
+
edge the CLI derives from frontmatter, not a `relations:` type) instantly.
|
|
70
71
|
- **Agent-native, tool-agnostic** — the same KB serves a standalone CLI,
|
|
71
72
|
Claude Code / Codex skills, an MCP server (Cursor, Windsurf, …), and opens
|
|
72
73
|
directly as an Obsidian vault.
|
|
@@ -91,14 +92,31 @@ npx @sdsrs/llm-wiki@0 ask "what did we decide about X?"
|
|
|
91
92
|
| `graph` | query `graph.json` with `path` / `neighbors` / `hubs` (zero-LLM traversal) |
|
|
92
93
|
| `embed` | compute/update page embeddings (`wiki/.vectors.json`) for optional vector location |
|
|
93
94
|
| `export` | export the graph as GraphML, Cypher, JSON Canvas, or an interactive HTML viewer; or the wiki as standard-markdown copies |
|
|
95
|
+
| `connect <projectDir>` | register a KB into a project's `CLAUDE.md` (sentinel block) so coding agents use it |
|
|
96
|
+
| `install-skills` | copy the bundled `wiki-*` skills into a `.claude` directory |
|
|
94
97
|
| `mcp` | run a read-only MCP server (stdio) over the KB |
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
load) and `--retrieve-only`
|
|
99
|
+
Run `llm-wiki --version` to print the version. All commands take `--kb <dir>`
|
|
100
|
+
(default `.`). `ask` supports `-k <n>` (pages to load) and `--retrieve-only`
|
|
101
|
+
(locate pages without calling the LLM). `scan` supports `--exclude <pattern...>`
|
|
102
|
+
and `--follow-symlinks` (follow symlinked source files that resolve outside the
|
|
103
|
+
source dir — off by default, since such a link can read an arbitrary file into
|
|
104
|
+
the KB).
|
|
98
105
|
|
|
99
106
|
`scan` also warns when the source looks multi-domain (mixed-language files or
|
|
100
107
|
dispersed wiki tags) and suggests splitting — one KB per domain.
|
|
101
108
|
|
|
109
|
+
### Operational envelope
|
|
110
|
+
|
|
111
|
+
llm-wiki targets **single-user, single-domain KBs at the hundreds-of-pages
|
|
112
|
+
scale**. Within that envelope everything is in-memory and re-read per operation
|
|
113
|
+
(no persistent index); there is no locking, so **do not run two writers against
|
|
114
|
+
one KB concurrently** (a CLI build alongside a long-lived MCP server is fine —
|
|
115
|
+
MCP is read-only). Source files above `maxFileBytes` (`wiki.config.json`, default
|
|
116
|
+
50 MB) are skipped. `wiki/log.md` is append-only and not rotated. BM25 tokenizes
|
|
117
|
+
CJK by single char + bigram and ASCII by word, so purely **kana/hangul** queries
|
|
118
|
+
won't match — enable vector location for robust cross-language retrieval.
|
|
119
|
+
|
|
102
120
|
### Asking questions
|
|
103
121
|
|
|
104
122
|
Retrieval is lexical (BM25) by default. When it finds nothing — typically a
|
package/bin/llm-wiki.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { program } from 'commander'
|
|
@@ -15,7 +16,9 @@ import { exportGraph, loadGraph, exportMarkdownPages } from '../src/export.mjs'
|
|
|
15
16
|
import { shortestPath, neighborhood, hubs } from '../src/graph.mjs'
|
|
16
17
|
import { runMcpServer } from '../src/mcp.mjs'
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
20
|
+
|
|
21
|
+
program.name('llm-wiki').description('Compile messy directories into an llm_wiki knowledge base').version(pkg.version)
|
|
19
22
|
|
|
20
23
|
program.command('init [dir]').description('scaffold a knowledge base').action((dir = '.') => {
|
|
21
24
|
const { created, skipped } = initKb(dir)
|
|
@@ -26,8 +29,9 @@ program.command('scan <srcDir>')
|
|
|
26
29
|
.description('inventory a source directory: dedup, batches, token estimate')
|
|
27
30
|
.option('--kb <dir>', 'knowledge base root', '.')
|
|
28
31
|
.option('--exclude <pattern...>', 'substring patterns to skip')
|
|
32
|
+
.option('--follow-symlinks', 'follow symlinked files that resolve outside the source dir (unsafe: only for trusted corpora)')
|
|
29
33
|
.action(async (srcDir, opts) => {
|
|
30
|
-
const r = await scanSource(srcDir, opts.kb, { exclude: opts.exclude ?? [] })
|
|
34
|
+
const r = await scanSource(srcDir, opts.kb, { exclude: opts.exclude ?? [], followSymlinks: !!opts.followSymlinks })
|
|
31
35
|
console.log(`files: ${r.files.length} (skipped ${r.skipped.length})`)
|
|
32
36
|
console.log(`duplicates: ${r.duplicates.exact.length} exact, ${r.duplicates.near.length} near`)
|
|
33
37
|
console.log(`incremental: +${r.incremental.added} ~${r.incremental.changed} -${r.incremental.removed} =${r.incremental.unchanged}`)
|
package/package.json
CHANGED
package/src/ask.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { estimateTokens } from './scanner.mjs'
|
|
|
8
8
|
import { loadLlmConfig, makeTransport } from './llm-config.mjs'
|
|
9
9
|
import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
|
|
10
10
|
import { embedTexts } from './embed.mjs'
|
|
11
|
+
import { fetchWithRetry } from './retry.mjs'
|
|
11
12
|
|
|
12
13
|
export function retrievePages(kbRoot, question, k = 6) {
|
|
13
14
|
const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
|
|
@@ -39,7 +40,7 @@ export function rrfFuse(lists, k) {
|
|
|
39
40
|
// stderr warning. 'bm25': lexical only, returns before any vector access.
|
|
40
41
|
// 'hybrid': explicit BM25+vector fusion — every missing prerequisite (and any
|
|
41
42
|
// vector error) throws instead of degrading, and vectorEnabled is ignored.
|
|
42
|
-
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto' } = {}) {
|
|
43
|
+
export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieval = 'auto', retry } = {}) {
|
|
43
44
|
if (!['auto', 'bm25', 'hybrid'].includes(retrieval)) throw new Error(`unknown retrieval mode: ${retrieval}`)
|
|
44
45
|
const bm25 = retrievePages(kbRoot, question, k)
|
|
45
46
|
const asBm25 = () => ({ hits: bm25.map(h => ({ ...h, sources: ['bm25'] })), usedVector: false })
|
|
@@ -59,7 +60,7 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
59
60
|
// fusing it produces silent garbage, so treat it as missing (not a failure).
|
|
60
61
|
if (store.model !== cfg.embeddingModel) return unavailable(`vector store was built with "${store.model}" but config says "${cfg.embeddingModel}" — re-run \`llm-wiki embed\``)
|
|
61
62
|
try {
|
|
62
|
-
const t = fetchImpl ? { fetchImpl, dispatcher: undefined } : await makeTransport()
|
|
63
|
+
const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
|
|
63
64
|
const [qv] = await embedTexts(cfg, t, [question])
|
|
64
65
|
const qn = normalize(qv)
|
|
65
66
|
const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
|
|
@@ -78,12 +79,11 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export async function chatCompletion(cfg, t, messages) {
|
|
81
|
-
const res = await t.fetchImpl
|
|
82
|
+
const res = await fetchWithRetry(t.fetchImpl, `${cfg.baseURL.replace(/\/$/, '')}/chat/completions`, {
|
|
82
83
|
method: 'POST',
|
|
83
84
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
|
|
84
85
|
body: JSON.stringify({ model: cfg.model, messages }),
|
|
85
|
-
|
|
86
|
-
})
|
|
86
|
+
}, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
|
|
87
87
|
if (!res.ok) {
|
|
88
88
|
let body = ''
|
|
89
89
|
try { body = (await res.text()).slice(0, 200) } catch { /* body unreadable; status alone */ }
|
|
@@ -130,9 +130,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
|
|
|
130
130
|
return picked.map(id => ({ relPath: `${id}.md`, score: 0 }))
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto' } = {}) {
|
|
133
|
+
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry } = {}) {
|
|
134
134
|
const p = kbPaths(kbRoot)
|
|
135
|
-
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval })
|
|
135
|
+
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry })
|
|
136
136
|
if (retrieveOnly) return { pages: hits, answer: null }
|
|
137
137
|
let validIds
|
|
138
138
|
if (hits.length === 0) {
|
|
@@ -145,7 +145,7 @@ export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fet
|
|
|
145
145
|
if (!cfg) throw new Error('No LLM configured. Create ~/.llm-wiki/config.json with {"baseURL","apiKey","model"} (OpenAI-compatible).')
|
|
146
146
|
// Injected fetchImpl (tests) is used as-is with no dispatcher; otherwise
|
|
147
147
|
// pick the proxy-aware transport (undici fetch + agent, or global fetch).
|
|
148
|
-
const t = fetchImpl ? { fetchImpl, dispatcher: undefined } : await makeTransport()
|
|
148
|
+
const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
|
|
149
149
|
let fallback = false
|
|
150
150
|
if (hits.length === 0) {
|
|
151
151
|
hits = await pickPagesFromListing(p, question, k, cfg, t, validIds)
|
package/src/embed.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { listWikiPages, isInvalidated } from './pages.mjs'
|
|
|
2
2
|
import { loadLlmConfig, makeTransport } from './llm-config.mjs'
|
|
3
3
|
import { sha256Text } from './hashing.mjs'
|
|
4
4
|
import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
|
|
5
|
+
import { fetchWithRetry } from './retry.mjs'
|
|
5
6
|
|
|
6
7
|
const BATCH = 64
|
|
7
8
|
// Safety margin under the 8192-token input limit of common embedding models
|
|
@@ -44,12 +45,11 @@ function capEmbedText(text) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export async function embedTexts(cfg, t, texts) {
|
|
47
|
-
const res = await t.fetchImpl
|
|
48
|
+
const res = await fetchWithRetry(t.fetchImpl, `${cfg.baseURL.replace(/\/$/, '')}/embeddings`, {
|
|
48
49
|
method: 'POST',
|
|
49
50
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
|
|
50
51
|
body: JSON.stringify({ model: cfg.embeddingModel, input: texts }),
|
|
51
|
-
|
|
52
|
-
})
|
|
52
|
+
}, { dispatcher: t.dispatcher, ...(t.retry ?? {}) })
|
|
53
53
|
if (!res.ok) {
|
|
54
54
|
let body = ''
|
|
55
55
|
try { body = (await res.text()).slice(0, 200) } catch { /* status alone */ }
|
|
@@ -62,7 +62,7 @@ export async function embedTexts(cfg, t, texts) {
|
|
|
62
62
|
return [...data.data].sort((a, b) => a.index - b.index).map(d => d.embedding)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export async function embedKb(kbRoot, { fetchImpl } = {}) {
|
|
65
|
+
export async function embedKb(kbRoot, { fetchImpl, retry } = {}) {
|
|
66
66
|
const cfg = loadLlmConfig(kbRoot)
|
|
67
67
|
if (!cfg) throw new Error('No LLM configured. Create ~/.llm-wiki/config.json (OpenAI-compatible).')
|
|
68
68
|
if (!cfg.embeddingModel) throw new Error('No embedding model configured. Add "embeddingModel" to your provider (or flat config) in ~/.llm-wiki/config.json, e.g. "text-embedding-3-small".')
|
|
@@ -84,7 +84,7 @@ export async function embedKb(kbRoot, { fetchImpl } = {}) {
|
|
|
84
84
|
let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
|
|
85
85
|
let embedded = 0
|
|
86
86
|
if (jobs.length > 0) {
|
|
87
|
-
const t = fetchImpl ? { fetchImpl, dispatcher: undefined } : await makeTransport()
|
|
87
|
+
const t = fetchImpl ? { fetchImpl, dispatcher: undefined, retry } : { ...(await makeTransport()), retry }
|
|
88
88
|
for (let i = 0; i < jobs.length; i += BATCH) {
|
|
89
89
|
const batch = jobs.slice(i, i + BATCH)
|
|
90
90
|
const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
|
|
@@ -96,6 +96,10 @@ export async function embedKb(kbRoot, { fetchImpl } = {}) {
|
|
|
96
96
|
nextPages[j.relPath] = { hash: j.hash, vec: n }
|
|
97
97
|
embedded++
|
|
98
98
|
})
|
|
99
|
+
// Incremental durability: persist after each batch so a later batch failing
|
|
100
|
+
// (rate-limit, network) doesn't discard this batch's work — the re-run reuses
|
|
101
|
+
// it by hash (zero repeat API cost). saveVectorStore is atomic (temp+rename).
|
|
102
|
+
saveVectorStore(kbRoot, { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages })
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
|
package/src/export.mjs
CHANGED
|
@@ -9,7 +9,7 @@ const xmlEscape = (s) => String(s)
|
|
|
9
9
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
10
10
|
.replace(/"/g, '"').replace(/'/g, ''')
|
|
11
11
|
|
|
12
|
-
const cyEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
12
|
+
const cyEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/'/g, "\\'")
|
|
13
13
|
|
|
14
14
|
// graph.json only lists wiki pages as nodes, but source-type edges point at raw/
|
|
15
15
|
// paths. Exports need every edge endpoint to exist, so synthesize raw nodes.
|
|
@@ -60,9 +60,16 @@ export function exportMarkdownPages(kbRoot, { out } = {}) {
|
|
|
60
60
|
const p = kbPaths(kbRoot)
|
|
61
61
|
const outDir = path.resolve(out ?? path.join(kbRoot, 'wiki-md'))
|
|
62
62
|
// Never let --out resolve onto a managed KB layer: the marker guard below would
|
|
63
|
-
// later rmSync it, wiping the immutable raw/ inputs or the wiki/ pages
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// later rmSync it, wiping the immutable raw/ inputs or the wiki/ pages. Prefix
|
|
64
|
+
// (not exact) match — `--out <kb>/raw/sub` is inside raw/ and just as destructive
|
|
65
|
+
// (audit MEDIUM-2). The KB root itself is rejected too.
|
|
66
|
+
const rawDir = path.resolve(kbRoot, 'raw')
|
|
67
|
+
const wikiDir = path.resolve(p.wiki)
|
|
68
|
+
const within = (parent, child) => {
|
|
69
|
+
const rel = path.relative(parent, child)
|
|
70
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
71
|
+
}
|
|
72
|
+
if (outDir === path.resolve(kbRoot) || within(rawDir, outDir) || within(wikiDir, outDir)) {
|
|
66
73
|
throw new Error(`refusing to export into the KB's managed layers (${path.relative(kbRoot, outDir) || '.'}) — pass a dedicated --out directory`)
|
|
67
74
|
}
|
|
68
75
|
if (fs.existsSync(outDir) && !fs.statSync(outDir).isDirectory()) {
|
package/src/indexer.mjs
CHANGED
|
@@ -17,6 +17,11 @@ export function extractWikilinks(body) {
|
|
|
17
17
|
|
|
18
18
|
const SECTION_TITLES = { source: 'Sources', entity: 'Entities', concept: 'Concepts', comparison: 'Comparisons', other: 'Other' }
|
|
19
19
|
|
|
20
|
+
// Frontmatter title/description are interpolated raw into line-based markdown
|
|
21
|
+
// (index.md, llms.txt). A value with newlines would inject extra lines, fake
|
|
22
|
+
// headings or spoof wikilinks (audit LOW-1). Collapse any newline run to a space.
|
|
23
|
+
const inline = (s) => String(s ?? '').replace(/[\r\n]+/g, ' ')
|
|
24
|
+
|
|
20
25
|
export function buildIndex(kbRoot) {
|
|
21
26
|
const p = kbPaths(kbRoot)
|
|
22
27
|
const cfg = loadKbConfig(kbRoot)
|
|
@@ -42,7 +47,7 @@ export function buildIndex(kbRoot) {
|
|
|
42
47
|
const byType = { source: [], entity: [], concept: [], comparison: [], other: [] }
|
|
43
48
|
for (const pg of pages) (byType[pg.data.type] ?? byType.other).push(pg)
|
|
44
49
|
const line = (pg) => {
|
|
45
|
-
const base = `- [[${pg.relPath.replace(/\.md$/, '')}]] — ${pg.data.description
|
|
50
|
+
const base = `- [[${pg.relPath.replace(/\.md$/, '')}]] — ${inline(pg.data.description)}`
|
|
46
51
|
if (!isInvalidated(pg)) return base
|
|
47
52
|
return pg.data.superseded_by
|
|
48
53
|
? `${base} ⚠ invalidated, superseded by [[${pg.data.superseded_by}]]`
|
|
@@ -50,15 +55,27 @@ export function buildIndex(kbRoot) {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
let indexBody = '# Index\n\n> Auto-generated by `llm-wiki index`. Only the "Pending concepts" section may be edited by the LLM.\n'
|
|
58
|
+
// Prune stale topics/*.md so a type that emptied (or a drop back below the split
|
|
59
|
+
// threshold) can't leave an orphaned list that exportMarkdownPages would copy.
|
|
60
|
+
const pruneTopics = (keep = new Set()) => {
|
|
61
|
+
if (!fs.existsSync(p.topics)) return
|
|
62
|
+
for (const f of fs.readdirSync(p.topics)) {
|
|
63
|
+
if (f.endsWith('.md') && !keep.has(f)) fs.rmSync(path.join(p.topics, f))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
53
66
|
const topicsSplit = pages.length > cfg.indexSplitAt
|
|
54
67
|
if (topicsSplit) {
|
|
55
68
|
fs.mkdirSync(p.topics, { recursive: true })
|
|
69
|
+
const written = new Set()
|
|
56
70
|
for (const [type, list] of Object.entries(byType)) {
|
|
57
71
|
if (!list.length) continue
|
|
58
72
|
fs.writeFileSync(path.join(p.topics, `${type}.md`), `# ${SECTION_TITLES[type]}\n\n${list.map(line).join('\n')}\n`)
|
|
73
|
+
written.add(`${type}.md`)
|
|
59
74
|
indexBody += `\n## ${SECTION_TITLES[type]}\nSee [[topics/${type}]] (${list.length} pages)\n`
|
|
60
75
|
}
|
|
76
|
+
pruneTopics(written)
|
|
61
77
|
} else {
|
|
78
|
+
pruneTopics() // no longer split: index.md inlines everything; drop any prior topic files
|
|
62
79
|
for (const [type, list] of Object.entries(byType)) {
|
|
63
80
|
if (type === 'other' && !list.length) continue
|
|
64
81
|
indexBody += `\n## ${SECTION_TITLES[type]}\n${list.map(line).join('\n')}\n`
|
|
@@ -101,7 +118,7 @@ export function buildIndex(kbRoot) {
|
|
|
101
118
|
|
|
102
119
|
const kbName = path.basename(path.resolve(kbRoot))
|
|
103
120
|
const llms = [`# ${kbName}`, '', `> llm_wiki knowledge base. Read wiki/index.md first, then open full pages.`, '', '## Pages',
|
|
104
|
-
...pages.filter(pg => !isInvalidated(pg)).map(pg => `- [${pg.data.title}](wiki/${pg.relPath}): ${pg.data.description
|
|
121
|
+
...pages.filter(pg => !isInvalidated(pg)).map(pg => `- [${inline(pg.data.title)}](wiki/${pg.relPath}): ${inline(pg.data.description)}`)].join('\n') + '\n'
|
|
105
122
|
fs.writeFileSync(p.llmsTxt, llms)
|
|
106
123
|
|
|
107
124
|
return { pageCount: pages.length, topicsSplit }
|
package/src/manifest.mjs
CHANGED
|
@@ -5,12 +5,19 @@ import { readJsonFile } from './json.mjs'
|
|
|
5
5
|
export function loadManifest(kbRoot) {
|
|
6
6
|
const p = kbPaths(kbRoot)
|
|
7
7
|
if (!fs.existsSync(p.manifest)) return { files: {} }
|
|
8
|
-
|
|
8
|
+
const m = readJsonFile(p.manifest)
|
|
9
|
+
// Shape guard: a hand-edited or half-written manifest lacking `files` (or with a
|
|
10
|
+
// non-object there) would make diffManifest's `Object.keys(manifest.files)` throw.
|
|
11
|
+
return (m && typeof m.files === 'object' && m.files !== null && !Array.isArray(m.files)) ? m : { files: {} }
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
export function saveManifest(kbRoot, manifest) {
|
|
12
15
|
const p = kbPaths(kbRoot)
|
|
13
|
-
|
|
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)
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export function diffManifest(manifest, entries) {
|
package/src/mcp.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.ur
|
|
|
17
17
|
function textResult(text) { return { content: [{ type: 'text', text }] } }
|
|
18
18
|
function errorResult(text) { return { content: [{ type: 'text', text }], isError: true } }
|
|
19
19
|
|
|
20
|
-
export function createMcpServer(kbRoot, { fetchImpl } = {}) {
|
|
20
|
+
export function createMcpServer(kbRoot, { fetchImpl, retry } = {}) {
|
|
21
21
|
const p = kbPaths(kbRoot)
|
|
22
22
|
const server = new McpServer({ name: 'llm-wiki', version: pkg.version })
|
|
23
23
|
|
|
@@ -38,7 +38,7 @@ export function createMcpServer(kbRoot, { fetchImpl } = {}) {
|
|
|
38
38
|
}, async ({ query, k = 6 }) => {
|
|
39
39
|
// 'auto' mode: opt-in via the KB's vectorEnabled, fail-open on any vector
|
|
40
40
|
// error — KBs without embeddings keep the exact pre-v2.5 BM25 behavior.
|
|
41
|
-
const { hits, usedVector } = await locatePages(kbRoot, query, { k, ...(fetchImpl ? { fetchImpl } : {}) })
|
|
41
|
+
const { hits, usedVector } = await locatePages(kbRoot, query, { k, retry, ...(fetchImpl ? { fetchImpl } : {}) })
|
|
42
42
|
if (hits.length === 0) {
|
|
43
43
|
return textResult(usedVector
|
|
44
44
|
? 'No match from BM25 or vector retrieval. Call wiki_overview and pick pages from the catalog yourself.'
|
|
@@ -79,7 +79,7 @@ export function createMcpServer(kbRoot, { fetchImpl } = {}) {
|
|
|
79
79
|
inputSchema: { question: z.string(), k: z.number().int().min(1).max(20).optional() },
|
|
80
80
|
}, async ({ question, k = 6 }) => {
|
|
81
81
|
try {
|
|
82
|
-
const r = await askKb(kbRoot, question, { k, ...(fetchImpl ? { fetchImpl } : {}) })
|
|
82
|
+
const r = await askKb(kbRoot, question, { k, retry, ...(fetchImpl ? { fetchImpl } : {}) })
|
|
83
83
|
const parts = [DATA_NOTICE, '', r.answer, '', `--- pages used: ${r.pages.map(h => h.relPath).join(', ')}`]
|
|
84
84
|
if (r.fallback) parts.push('(BM25 had no lexical match; pages were selected from the KB listing by the model)')
|
|
85
85
|
if (r.trimmed?.length) parts.push(`(token budget: dropped ${r.trimmed.length} lower-ranked page(s): ${r.trimmed.join(', ')})`)
|
package/src/retry.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504])
|
|
2
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
3
|
+
|
|
4
|
+
// One network call to an OpenAI-compatible endpoint, hardened for a CLI that must
|
|
5
|
+
// not hang forever or die on a transient rate-limit. Per attempt: an
|
|
6
|
+
// AbortSignal.timeout ceiling (its timer is unref'd, so it never keeps the process
|
|
7
|
+
// alive); retry on 429/5xx and on network/abort errors with exponential backoff.
|
|
8
|
+
// On a persistent 429/5xx the final response is returned so the caller raises its
|
|
9
|
+
// own "API error <status>"; on a persistent network/abort error the last error is
|
|
10
|
+
// thrown. retries=0 disables retry (immediate surface — used by tests).
|
|
11
|
+
export async function fetchWithRetry(fetchImpl, url, opts, { dispatcher, timeoutMs = 120000, retries = 3, backoffMs = 500 } = {}) {
|
|
12
|
+
for (let attempt = 0; ; attempt++) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetchImpl(url, {
|
|
15
|
+
...opts,
|
|
16
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
17
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
18
|
+
})
|
|
19
|
+
if (RETRYABLE_STATUS.has(res.status) && attempt < retries) {
|
|
20
|
+
await sleep(backoffMs * 2 ** attempt)
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
return res
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (attempt >= retries) throw err
|
|
26
|
+
await sleep(backoffMs * 2 ** attempt)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/scanner.mjs
CHANGED
|
@@ -51,38 +51,52 @@ export function estimateTokens(text) {
|
|
|
51
51
|
|
|
52
52
|
// Symlinked directories are not followed (loop safety) but are recorded in
|
|
53
53
|
// `skippedDirs` so they surface in the scan report instead of vanishing silently.
|
|
54
|
-
// Symlinked files
|
|
55
|
-
|
|
54
|
+
// Symlinked files are followed ONLY when their target resolves inside the source
|
|
55
|
+
// tree (`opts.rootReal`). A link escaping the tree would pull an arbitrary readable
|
|
56
|
+
// file (e.g. ~/.llm-wiki/config.json, ~/.ssh/id_rsa, /dev/zero) into raw/ and into a
|
|
57
|
+
// publishable page — the HIGH-1 exfiltration/DoS vector. `opts.followSymlinks` opts
|
|
58
|
+
// back into follow-anywhere for a trusted, curated corpus.
|
|
59
|
+
function* walk(dir, base, skippedDirs, exclude, opts) {
|
|
56
60
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
57
61
|
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
58
62
|
const abs = path.join(dir, e.name)
|
|
59
63
|
if (e.isSymbolicLink()) {
|
|
60
64
|
const rel = path.relative(base, abs)
|
|
61
65
|
if (exclude.some(pat => rel.includes(pat))) { skippedDirs.push({ rel, reason: 'excluded' }); continue }
|
|
62
|
-
let st
|
|
63
|
-
try { st = fs.statSync(abs) } catch { skippedDirs.push({ rel, reason: 'broken symlink' }); continue }
|
|
66
|
+
let st, real
|
|
67
|
+
try { st = fs.statSync(abs); real = fs.realpathSync(abs) } catch { skippedDirs.push({ rel, reason: 'broken symlink' }); continue }
|
|
64
68
|
if (st.isDirectory()) { skippedDirs.push({ rel, reason: 'symlinked directory (not followed)' }); continue }
|
|
69
|
+
if (!opts.followSymlinks) {
|
|
70
|
+
const relToRoot = path.relative(opts.rootReal, real)
|
|
71
|
+
if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
|
|
72
|
+
skippedDirs.push({ rel, reason: 'symlink escapes source dir' }); continue
|
|
73
|
+
}
|
|
74
|
+
}
|
|
65
75
|
yield rel
|
|
66
|
-
} else if (e.isDirectory()) yield* walk(abs, base, skippedDirs, exclude)
|
|
76
|
+
} else if (e.isDirectory()) yield* walk(abs, base, skippedDirs, exclude, opts)
|
|
67
77
|
else yield path.relative(base, abs)
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
// `persist: false` runs a read-only scan (e.g. for `status`) that does not
|
|
72
82
|
// overwrite the .scan-plan.json a previous explicit `scan` produced.
|
|
73
|
-
export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true } = {}) {
|
|
83
|
+
export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true, followSymlinks = false } = {}) {
|
|
74
84
|
const st = fs.statSync(srcDir, { throwIfNoEntry: false })
|
|
75
85
|
if (!st) throw new Error(`source directory not found: ${srcDir}`)
|
|
76
86
|
if (!st.isDirectory()) throw new Error(`source path is not a directory: ${srcDir}`)
|
|
77
87
|
const cfg = loadKbConfig(kbRoot)
|
|
78
88
|
const files = []
|
|
79
89
|
const skipped = []
|
|
80
|
-
|
|
90
|
+
const rootReal = fs.realpathSync(srcDir)
|
|
91
|
+
for (const rel of walk(srcDir, srcDir, skipped, exclude, { followSymlinks, rootReal })) {
|
|
81
92
|
if (exclude.some(pat => rel.includes(pat))) { skipped.push({ rel, reason: 'excluded' }); continue }
|
|
82
93
|
const ext = path.extname(rel).toLowerCase()
|
|
83
94
|
if (!SUPPORTED_EXTS.includes(ext)) { skipped.push({ rel, reason: `unsupported ${ext}` }); continue }
|
|
84
95
|
const abs = path.join(srcDir, rel)
|
|
85
96
|
const bytes = fs.statSync(abs).size
|
|
97
|
+
// Size cap (audit MEDIUM-1): an unbounded read OOMs on a hostile multi-GB file;
|
|
98
|
+
// gate before sha256File/readFileSync below, which both read the whole file.
|
|
99
|
+
if (bytes > cfg.maxFileBytes) { skipped.push({ rel, reason: `too large (${bytes} bytes > ${cfg.maxFileBytes} cap)` }); continue }
|
|
86
100
|
const entry = { rel, ext, bytes, hash: sha256File(abs), lang: 'unknown', tokens: 0 }
|
|
87
101
|
if (TEXT_EXTS.includes(ext)) {
|
|
88
102
|
const text = fs.readFileSync(abs, 'utf8')
|
package/src/templates.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const DEFAULT_CONFIG = {
|
|
|
11
11
|
language: 'auto',
|
|
12
12
|
linkStyle: 'wikilink',
|
|
13
13
|
askTokenBudget: 32000,
|
|
14
|
+
maxFileBytes: 52428800,
|
|
14
15
|
vectorEnabled: false,
|
|
15
16
|
relationTypes: ['implements', 'uses', 'depends_on', 'part_of', 'instance_of', 'derived_from', 'contrasts_with', 'causes'],
|
|
16
17
|
}
|
|
@@ -117,7 +118,7 @@ export function readmeTemplate(name) {
|
|
|
117
118
|
|
|
118
119
|
An llm_wiki knowledge base. Layers: \`raw/\` (immutable sources), \`wiki/\` (LLM-maintained pages).
|
|
119
120
|
|
|
120
|
-
- Ask questions standalone: \`npx @sdsrs/llm-wiki ask "your question"\` (configure the LLM API in \`~/.llm-wiki/config.json\`).
|
|
121
|
+
- Ask questions standalone: \`npx @sdsrs/llm-wiki@0 ask "your question"\` (configure the LLM API in \`~/.llm-wiki/config.json\`).
|
|
121
122
|
- Browse: open \`wiki/index.md\`, or open this folder as an Obsidian vault.
|
|
122
123
|
- Maintain with a coding agent: see \`AGENTS.md\`.
|
|
123
124
|
`
|
package/src/vector.mjs
CHANGED
|
@@ -36,7 +36,13 @@ export function loadVectorStore(kbRoot) {
|
|
|
36
36
|
export function saveVectorStore(kbRoot, store) {
|
|
37
37
|
const rounded = { ...store, pages: Object.fromEntries(Object.entries(store.pages).map(([id, e]) =>
|
|
38
38
|
[id, { hash: e.hash, vec: e.vec.map(x => Number(x.toFixed(5))) }])) }
|
|
39
|
-
|
|
39
|
+
// Atomic write: the per-batch flush in embedKb re-saves repeatedly; a crash
|
|
40
|
+
// mid-write must not leave a truncated store (loadVectorStore fails open, but
|
|
41
|
+
// temp+rename avoids the corruption entirely).
|
|
42
|
+
const f = vectorStorePath(kbRoot)
|
|
43
|
+
const tmp = `${f}.tmp`
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(rounded) + '\n')
|
|
45
|
+
fs.renameSync(tmp, f)
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// queryVec must already be normalized; stored vecs are normalized at save time,
|