@sdsrs/llm-wiki 0.9.0 → 0.9.2
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 +60 -0
- package/bin/llm-wiki.mjs +20 -8
- package/package.json +1 -1
- package/src/ask.mjs +9 -2
- package/src/connect.mjs +4 -0
- package/src/embed.mjs +31 -5
- package/src/frontmatter.mjs +8 -1
- package/src/json.mjs +20 -3
- package/src/lint.mjs +22 -2
- package/src/llm-config.mjs +31 -11
- package/src/local-embed.mjs +13 -2
- package/src/scanner.mjs +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.2 (2026-07-12)
|
|
4
|
+
|
|
5
|
+
Robustness and UX fixes from an end-to-end usage audit; no change to core retrieval or
|
|
6
|
+
convert behavior. Suite 271 → 279.
|
|
7
|
+
|
|
8
|
+
**Fixed:**
|
|
9
|
+
|
|
10
|
+
- **Concurrent writes to a derived file no longer crash.** `writeFileAtomic` used a fixed
|
|
11
|
+
temp name, so two writers to the same file (concurrent `index`, or the MCP server and a
|
|
12
|
+
CLI command touching the same derived file) collided — the second renamed a temp the
|
|
13
|
+
first had already consumed and died with ENOENT. Each write now uses a unique temp; the
|
|
14
|
+
rename onto the target stays atomic, so readers still never see a torn write.
|
|
15
|
+
- **`graph` accepts `--kb` on the parent command** (`graph --kb ./kb hubs`), like every
|
|
16
|
+
other command — it previously errored "unknown option --kb" unless `--kb` came after the
|
|
17
|
+
subcommand.
|
|
18
|
+
- **`ask` rejects an empty/whitespace question** instead of running a no-op search.
|
|
19
|
+
- **`ask --retrieve-only` tells an empty wiki apart from a no-match query** — it no longer
|
|
20
|
+
tells a user with a populated KB to go build pages they already have.
|
|
21
|
+
- **`connect` reports a clear error for a missing/non-directory project path** instead of
|
|
22
|
+
leaking a raw ENOENT for an internal file.
|
|
23
|
+
- **`scan` no longer reports a phantom "+1 to convert" for an exact-duplicate file** — the
|
|
24
|
+
incremental count now agrees with the compile plan (duplicates are reported separately).
|
|
25
|
+
- **Clearer frontmatter error**: a page that opens `---` but never closes it is reported as
|
|
26
|
+
`unterminated-frontmatter`, not `missing-frontmatter`.
|
|
27
|
+
- **Clearer `embed` error** when no embedding model is configured (names the field to set,
|
|
28
|
+
for both remote and local).
|
|
29
|
+
|
|
30
|
+
**Added (lint):**
|
|
31
|
+
|
|
32
|
+
- `type-dir-mismatch` — flags a page whose `type` disagrees with its directory (e.g.
|
|
33
|
+
`type: source` in `concepts/`), which the indexer would otherwise silently miscategorize.
|
|
34
|
+
- `self-supersede` — flags a page whose `superseded_by` points at itself.
|
|
35
|
+
|
|
36
|
+
**Changed (internal):**
|
|
37
|
+
|
|
38
|
+
- `loadEmbedConfig` reads the global config once per call and resolves a provider-level
|
|
39
|
+
embedding model in priority order, consistent with chat provider selection.
|
|
40
|
+
- Local embedding no longer builds a network transport (no undici import / proxy agent),
|
|
41
|
+
since it makes no network calls.
|
|
42
|
+
|
|
43
|
+
## 0.9.1 (2026-07-12)
|
|
44
|
+
|
|
45
|
+
Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
|
|
46
|
+
vectors produced. Suite 271 → 272.
|
|
47
|
+
|
|
48
|
+
**Fixed:**
|
|
49
|
+
|
|
50
|
+
- **Long pages no longer truncate silently on local models.** A local model
|
|
51
|
+
(e.g. `local:Xenova/multilingual-e5-small`) has a ~512-token input window and its
|
|
52
|
+
pipeline silently truncates longer pages to a head-only vector. `embed` now warns per
|
|
53
|
+
page and reports a `truncated N` count in its summary, so the head-only truncation is
|
|
54
|
+
visible instead of silent. The vector produced is unchanged.
|
|
55
|
+
- **A broken transitive dependency is no longer masked as "install
|
|
56
|
+
@huggingface/transformers".** The missing-dependency hint now fires only when
|
|
57
|
+
transformers.js itself is absent; when transformers.js is installed but one of its
|
|
58
|
+
(native) deps fails to load, the original error naming the real culprit passes through.
|
|
59
|
+
- **Clearer `embed` error when no embedding model is configured** — it now names the
|
|
60
|
+
actual fix (set `embeddingModel`) for both remote and local, instead of telling a user
|
|
61
|
+
who already has chat credentials to add credentials.
|
|
62
|
+
|
|
3
63
|
## 0.9.0 (2026-07-12)
|
|
4
64
|
|
|
5
65
|
**Added:**
|
package/bin/llm-wiki.mjs
CHANGED
|
@@ -77,7 +77,8 @@ program.command('embed')
|
|
|
77
77
|
.option('--kb <dir>', 'knowledge base root', '.')
|
|
78
78
|
.action(async (opts) => {
|
|
79
79
|
const r = await embedKb(opts.kb)
|
|
80
|
-
|
|
80
|
+
const trunc = r.truncated ? `, truncated ${r.truncated}` : ''
|
|
81
|
+
console.log(`embedded ${r.embedded}, reused ${r.reused}, pruned ${r.pruned}${trunc} (model ${r.model}, dim ${r.dim})`)
|
|
81
82
|
})
|
|
82
83
|
|
|
83
84
|
program.command('ask <question>')
|
|
@@ -88,10 +89,16 @@ program.command('ask <question>')
|
|
|
88
89
|
.action(async (question, opts) => {
|
|
89
90
|
const k = Number.parseInt(opts.k, 10)
|
|
90
91
|
if (!Number.isFinite(k) || k < 1) { console.error(`invalid -k value: ${opts.k} (expected a positive integer)`); process.exit(1) }
|
|
92
|
+
if (!question.trim()) { console.error('empty question — provide something to ask'); process.exit(1) }
|
|
91
93
|
const r = await askKb(opts.kb, question, { k, retrieveOnly: opts.retrieveOnly })
|
|
92
94
|
if (opts.retrieveOnly) {
|
|
93
95
|
if (r.pages.length === 0) {
|
|
94
|
-
|
|
96
|
+
// Distinguish "wiki is empty" (build pages) from "wiki has pages but none
|
|
97
|
+
// matched" (rephrase) — the old fixed message told users with a full KB to go
|
|
98
|
+
// build pages they already have.
|
|
99
|
+
console.error(r.kbEmpty
|
|
100
|
+
? 'no pages located — the wiki has no pages yet (build them with the wiki-build skill, or check that wiki/ contains pages)'
|
|
101
|
+
: 'no pages located — no wiki page matched your query (the wiki has pages; try different or fewer search terms)')
|
|
95
102
|
return
|
|
96
103
|
}
|
|
97
104
|
for (const h of r.pages) console.log(`${h.score.toFixed(3)} ${h.relPath} [${(h.sources ?? ['bm25']).join('+')}]`)
|
|
@@ -163,12 +170,17 @@ program.command('export')
|
|
|
163
170
|
})
|
|
164
171
|
|
|
165
172
|
const graphCmd = program.command('graph').description('query wiki/graph.json: path | neighbors | hubs (zero-LLM traversal)')
|
|
173
|
+
// Accept --kb on the parent too, so `graph --kb ./kb hubs` works like every other
|
|
174
|
+
// command (`ask --kb`, `lint --kb`). Subcommands keep their own --kb (no default) and
|
|
175
|
+
// fall back to the parent's, so both placements resolve and neither silently wins '.'.
|
|
176
|
+
.option('--kb <dir>', 'knowledge base root', '.')
|
|
177
|
+
const graphKb = (opts) => opts.kb ?? graphCmd.opts().kb
|
|
166
178
|
|
|
167
179
|
graphCmd.command('path <from> <to>')
|
|
168
180
|
.description('shortest link chain between two page ids (either link direction)')
|
|
169
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
181
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
170
182
|
.action((from, to, opts) => {
|
|
171
|
-
const r = shortestPath(loadGraph(opts
|
|
183
|
+
const r = shortestPath(loadGraph(graphKb(opts)), from, to)
|
|
172
184
|
if (!r) { console.log(`no path between ${from} and ${to}`); return }
|
|
173
185
|
console.log(r.nodes[0])
|
|
174
186
|
for (const h of r.hops) console.log(` ${h.dir === 'out' ? `-[${h.type}]->` : `<-[${h.type}]-`} ${h.to}${h.confidence ? ` (${h.confidence})` : ''}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
|
|
@@ -177,11 +189,11 @@ graphCmd.command('path <from> <to>')
|
|
|
177
189
|
graphCmd.command('neighbors <id>')
|
|
178
190
|
.description('pages within N hops (links counted in both directions)')
|
|
179
191
|
.option('-d, --depth <n>', 'expansion depth', '1')
|
|
180
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
192
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
181
193
|
.action((id, opts) => {
|
|
182
194
|
const depth = Number.parseInt(opts.depth, 10)
|
|
183
195
|
if (!Number.isFinite(depth) || depth < 1) { console.error(`invalid depth: ${opts.depth} (expected a positive integer)`); process.exit(1) }
|
|
184
|
-
const r = neighborhood(loadGraph(opts
|
|
196
|
+
const r = neighborhood(loadGraph(graphKb(opts)), id, depth)
|
|
185
197
|
if (!r.length) { console.log(`${id} has no linked neighbors`); return }
|
|
186
198
|
for (const n of r) console.log(`d=${n.distance} ${n.id} [${n.type}${n.confidence ? '/' + n.confidence : ''} ${n.dir}]${n.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
|
|
187
199
|
})
|
|
@@ -189,11 +201,11 @@ graphCmd.command('neighbors <id>')
|
|
|
189
201
|
graphCmd.command('hubs')
|
|
190
202
|
.description('most-connected pages (degree ranking; raw files excluded)')
|
|
191
203
|
.option('--top <n>', 'how many to show', '10')
|
|
192
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
204
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
193
205
|
.action((opts) => {
|
|
194
206
|
const top = Number.parseInt(opts.top, 10)
|
|
195
207
|
if (!Number.isFinite(top) || top < 1) { console.error(`invalid --top: ${opts.top} (expected a positive integer)`); process.exit(1) }
|
|
196
|
-
for (const h of hubs(loadGraph(opts
|
|
208
|
+
for (const h of hubs(loadGraph(graphKb(opts)), { top })) {
|
|
197
209
|
console.log(`${String(h.degree).padStart(3)} ${h.id} (in ${h.in} / out ${h.out}) ${h.title}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
|
|
198
210
|
}
|
|
199
211
|
})
|
package/package.json
CHANGED
package/src/ask.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { worstCaseTokens } from './scanner.mjs'
|
|
|
8
8
|
import { loadLlmConfig, loadEmbedConfig, makeTransport } from './llm-config.mjs'
|
|
9
9
|
import { loadVectorStore, normalize, cosineTopK } from './vector.mjs'
|
|
10
10
|
import { embedTexts } from './embed.mjs'
|
|
11
|
+
import { isLocalModel } from './local-embed.mjs'
|
|
11
12
|
import { fetchWithRetry } from './retry.mjs'
|
|
12
13
|
|
|
13
14
|
// Two per-KB caches, both keyed by a cheap page-set freshness token so neither can
|
|
@@ -149,7 +150,11 @@ export async function locatePages(kbRoot, question, { k = 6, fetchImpl, retrieva
|
|
|
149
150
|
const injected = fetchImpl || pipelineFactory
|
|
150
151
|
const t = injected
|
|
151
152
|
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
152
|
-
|
|
153
|
+
// A local query embedding makes no network call (embedLocal ignores the
|
|
154
|
+
// transport), so skip makeTransport — no undici import / proxy agent for nothing.
|
|
155
|
+
: isLocalModel(cfg.embeddingModel)
|
|
156
|
+
? { retry }
|
|
157
|
+
: { ...(await makeTransport()), retry }
|
|
153
158
|
const [qv] = await embedTexts(cfg, t, [question], { role: 'query' })
|
|
154
159
|
const qn = normalize(qv)
|
|
155
160
|
const vecHits = qn ? cosineTopK(qn, store, k).map(v => ({ relPath: v.id, score: v.score })) : []
|
|
@@ -232,7 +237,9 @@ async function pickPagesFromListing(p, question, k, cfg, t, validIds) {
|
|
|
232
237
|
export async function askKb(kbRoot, question, { k = 6, retrieveOnly = false, fetchImpl, retrieval = 'auto', retry, pipelineFactory } = {}) {
|
|
233
238
|
const p = kbPaths(kbRoot)
|
|
234
239
|
let { hits } = await locatePages(kbRoot, question, { k, fetchImpl, retrieval, retry, pipelineFactory })
|
|
235
|
-
|
|
240
|
+
// kbEmpty lets the CLI tell "no pages built yet" apart from "pages exist but none
|
|
241
|
+
// matched". Only pay the livePages listing when there were zero hits to explain.
|
|
242
|
+
if (retrieveOnly) return { pages: hits, answer: null, kbEmpty: hits.length === 0 && livePages(kbRoot).length === 0 }
|
|
236
243
|
let validIds
|
|
237
244
|
if (hits.length === 0) {
|
|
238
245
|
validIds = new Set(livePages(kbRoot).map(pg => pg.relPath.replace(/\.md$/, '')))
|
package/src/connect.mjs
CHANGED
|
@@ -15,6 +15,10 @@ function renderBlock(kbs) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function connectProject(projectDir, { kb, role = 'project', remove = false }) {
|
|
18
|
+
// Fail with a clear message instead of leaking a raw ENOENT for an internal file
|
|
19
|
+
// (.llm-wiki.json) when the user points connect at a missing/typo'd project path.
|
|
20
|
+
if (!fs.existsSync(projectDir)) throw new Error(`project directory not found: ${projectDir}`)
|
|
21
|
+
if (!fs.statSync(projectDir).isDirectory()) throw new Error(`not a directory: ${projectDir}`)
|
|
18
22
|
const regFile = path.join(projectDir, '.llm-wiki.json')
|
|
19
23
|
const regExists = fs.existsSync(regFile)
|
|
20
24
|
const registry = regExists ? readJsonFile(regFile) : { kbs: [] }
|
package/src/embed.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { loadEmbedConfig, makeTransport } from './llm-config.mjs'
|
|
|
3
3
|
import { sha256Text } from './hashing.mjs'
|
|
4
4
|
import { normalize, pageEmbedText, loadVectorStore, saveVectorStore } from './vector.mjs'
|
|
5
5
|
import { fetchWithRetry } from './retry.mjs'
|
|
6
|
+
import { estimateTokens } from './scanner.mjs'
|
|
6
7
|
import { embedLocal, isLocalModel, stripLocalPrefix } from './local-embed.mjs'
|
|
7
8
|
|
|
8
9
|
const BATCH = 64
|
|
@@ -11,6 +12,15 @@ const BATCH = 64
|
|
|
11
12
|
// whole-page retrieval is unaffected — the page file and its BM25 text are never
|
|
12
13
|
// touched, only the vector's input is truncated so one huge page can't abort the run.
|
|
13
14
|
const EMBED_TOKEN_CAP = 8000
|
|
15
|
+
// Local (transformers.js) models don't reject over-limit input the way an API does —
|
|
16
|
+
// the feature-extraction pipeline silently truncates at the model's own window, so a
|
|
17
|
+
// page over it yields a head-only vector with NO error. e5-small and most Xenova
|
|
18
|
+
// sentence-transformers are 512 tokens; we keep the 8000 cap on the text we send (it
|
|
19
|
+
// bounds compute and stays well above the window so the pipeline still fills it) and
|
|
20
|
+
// warn separately, via a realistic token estimate, whenever a page crosses this window
|
|
21
|
+
// so the head-only truncation is visible instead of silent. A model with a different
|
|
22
|
+
// window would extend this into a per-model lookup.
|
|
23
|
+
const LOCAL_TOKEN_WINDOW = 512
|
|
14
24
|
|
|
15
25
|
// Worst-case token count for the embedding API — deliberately NOT scanner's
|
|
16
26
|
// estimateTokens (chars/4), which underestimates dense markdown/code where real
|
|
@@ -76,20 +86,32 @@ export async function embedTexts(cfg, t, texts, { role = 'passage' } = {}) {
|
|
|
76
86
|
|
|
77
87
|
export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}) {
|
|
78
88
|
const cfg = loadEmbedConfig(kbRoot)
|
|
79
|
-
if (!cfg?.embeddingModel) throw new Error('No
|
|
89
|
+
if (!cfg?.embeddingModel) throw new Error('No embedding model configured. Set "embeddingModel" in ~/.llm-wiki/config.json — a remote one (e.g. "text-embedding-3-small") also needs the provider baseURL/apiKey; a local one (e.g. "local:Xenova/multilingual-e5-small") needs no credentials.')
|
|
90
|
+
const local = isLocalModel(cfg.embeddingModel)
|
|
80
91
|
const pages = listWikiPages(kbRoot).filter(p => !p.error && !isInvalidated(p))
|
|
81
92
|
const prev = loadVectorStore(kbRoot)
|
|
82
93
|
const reuse = (prev && prev.model === cfg.embeddingModel) ? prev.pages : {}
|
|
83
94
|
const jobs = []
|
|
84
95
|
const nextPages = {}
|
|
85
96
|
let reused = 0
|
|
97
|
+
let truncatedCount = 0
|
|
86
98
|
for (const pg of pages) {
|
|
87
99
|
const raw = pageEmbedText(pg)
|
|
88
100
|
const text = capEmbedText(raw)
|
|
89
|
-
|
|
101
|
+
// API path: `truncated` means our worst-case cap actually cut the text (the API
|
|
102
|
+
// would otherwise reject it). Local path: the cap rarely fires (window ≪ 8000), so
|
|
103
|
+
// detect the model's OWN silent truncation with a realistic estimate against its
|
|
104
|
+
// window — that's what produces a head-only vector the user should know about.
|
|
105
|
+
const overWindow = local && estimateTokens(raw) > LOCAL_TOKEN_WINDOW
|
|
106
|
+
const truncated = overWindow || (!local && text !== raw)
|
|
90
107
|
const hash = sha256Text(text)
|
|
91
108
|
if (reuse[pg.relPath]?.hash === hash) { nextPages[pg.relPath] = reuse[pg.relPath]; reused++; continue }
|
|
92
|
-
if (truncated)
|
|
109
|
+
if (truncated) {
|
|
110
|
+
truncatedCount++
|
|
111
|
+
process.stderr.write(overWindow
|
|
112
|
+
? `warning: ${pg.relPath} exceeds the local embedding model's ~${LOCAL_TOKEN_WINDOW}-token window — its vector covers only the page head\n`
|
|
113
|
+
: `warning: ${pg.relPath} embed text truncated to ~${EMBED_TOKEN_CAP} worst-case tokens (page exceeds embedding model input limit)\n`)
|
|
114
|
+
}
|
|
93
115
|
jobs.push({ relPath: pg.relPath, text, hash })
|
|
94
116
|
}
|
|
95
117
|
let dim = (prev && prev.model === cfg.embeddingModel) ? prev.dim : null
|
|
@@ -98,7 +120,11 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
|
|
|
98
120
|
const injected = fetchImpl || pipelineFactory
|
|
99
121
|
const t = injected
|
|
100
122
|
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
101
|
-
|
|
123
|
+
// A local model makes no network call (embedLocal ignores the transport), so
|
|
124
|
+
// skip makeTransport — it would import undici / build a proxy agent for nothing.
|
|
125
|
+
: local
|
|
126
|
+
? { retry }
|
|
127
|
+
: { ...(await makeTransport()), retry }
|
|
102
128
|
for (let i = 0; i < jobs.length; i += BATCH) {
|
|
103
129
|
const batch = jobs.slice(i, i + BATCH)
|
|
104
130
|
const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
|
|
@@ -119,5 +145,5 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
|
|
|
119
145
|
const pruned = prev ? Object.keys(prev.pages).filter(id => !(id in nextPages)).length : 0
|
|
120
146
|
const store = { model: cfg.embeddingModel, dim: dim ?? 0, pages: nextPages }
|
|
121
147
|
saveVectorStore(kbRoot, store)
|
|
122
|
-
return { embedded, reused, pruned, model: cfg.embeddingModel, dim: store.dim }
|
|
148
|
+
return { embedded, reused, pruned, truncated: truncatedCount, model: cfg.embeddingModel, dim: store.dim }
|
|
123
149
|
}
|
package/src/frontmatter.mjs
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import YAML from 'yaml'
|
|
2
2
|
|
|
3
3
|
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
|
|
4
|
+
const FM_OPEN_RE = /^---\r?\n/
|
|
4
5
|
|
|
5
6
|
export function parseFrontmatter(text) {
|
|
6
7
|
const m = text.match(FM_RE)
|
|
7
|
-
if (!m)
|
|
8
|
+
if (!m) {
|
|
9
|
+
// Opens with a `---` delimiter but never closes it — an unterminated block, not
|
|
10
|
+
// an absent one. A forgotten closing `---` is a common authoring slip; report it
|
|
11
|
+
// distinctly so the fix is "close the frontmatter", not "add frontmatter".
|
|
12
|
+
if (FM_OPEN_RE.test(text)) return { error: 'unterminated-frontmatter' }
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
8
15
|
let data
|
|
9
16
|
try { data = YAML.parse(m[1]) } catch { return { error: 'invalid-yaml' } }
|
|
10
17
|
if (data === null || typeof data !== 'object') return { error: 'invalid-yaml' }
|
package/src/json.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
// Per-write temp counter: combined with the pid it keeps concurrent writers to the
|
|
5
|
+
// same target on distinct temp files even within one process (see writeFileAtomic).
|
|
6
|
+
let atomicSeq = 0
|
|
2
7
|
|
|
3
8
|
// JSON.parse with the offending file named in the error — a bare SyntaxError
|
|
4
9
|
// from a corrupt state file gives the user nothing to act on.
|
|
@@ -22,7 +27,19 @@ export function readJsonFile(file, { redactContents = false } = {}) {
|
|
|
22
27
|
// only ever sees the whole old file or the whole new one. Single source of the pattern
|
|
23
28
|
// that manifest/vector already used and buildIndex's derived stores previously missed.
|
|
24
29
|
export function writeFileAtomic(file, data) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
// Unique temp name per write. A fixed `${file}.tmp` makes two concurrent writers to
|
|
31
|
+
// the same target share one temp file: the first rename consumes it, the second then
|
|
32
|
+
// renames a now-missing file and crashes with ENOENT (concurrent `index`, or MCP +
|
|
33
|
+
// CLI touching the same derived file). pid + seq + random keeps temps independent.
|
|
34
|
+
// The rename onto `file` stays atomic, so readers still never see a torn write.
|
|
35
|
+
const tmp = `${file}.${process.pid}.${(atomicSeq++).toString(36)}.${randomBytes(4).toString('hex')}.tmp`
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(tmp, data)
|
|
38
|
+
fs.renameSync(tmp, file)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Unique temps don't self-overwrite on the next run, so a failed write must clean
|
|
41
|
+
// up after itself or it leaks a stray .tmp beside the target.
|
|
42
|
+
try { fs.unlinkSync(tmp) } catch { /* best-effort */ }
|
|
43
|
+
throw err
|
|
44
|
+
}
|
|
28
45
|
}
|
package/src/lint.mjs
CHANGED
|
@@ -7,6 +7,13 @@ import { extractWikilinks, buildIndex } from './indexer.mjs'
|
|
|
7
7
|
import { loadManifest } from './manifest.mjs'
|
|
8
8
|
import { writeFileAtomic } from './json.mjs'
|
|
9
9
|
|
|
10
|
+
// The AGENTS.md contract binds each page type to its directory (source↔sources/, …).
|
|
11
|
+
// A page whose `type` disagrees with its directory is silently miscategorized: the
|
|
12
|
+
// indexer groups index.md sections by `type`, but ids / wikilinks / graph nodes use
|
|
13
|
+
// the directory — so e.g. a `type: source` page in concepts/ is filed under "Sources"
|
|
14
|
+
// yet linked as `concepts/…`.
|
|
15
|
+
const DIR_TYPE = { sources: 'source', entities: 'entity', concepts: 'concept', comparisons: 'comparison' }
|
|
16
|
+
|
|
10
17
|
export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
11
18
|
const p = kbPaths(kbRoot)
|
|
12
19
|
const cfg = loadKbConfig(kbRoot)
|
|
@@ -20,6 +27,12 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
|
20
27
|
for (const pg of pages) {
|
|
21
28
|
if (pg.error) { mechanical.push({ rule: 'invalid-frontmatter', path: pg.relPath, detail: pg.error }); continue }
|
|
22
29
|
for (const issue of validatePage(pg)) mechanical.push({ rule: 'missing-field', path: pg.relPath, detail: issue })
|
|
30
|
+
// type must match the directory (AGENTS.md contract) — a mismatch is miscategorized
|
|
31
|
+
// by the indexer. Skip when type is absent (already flagged as a missing field).
|
|
32
|
+
const dir = pg.relPath.split('/')[0]
|
|
33
|
+
if (DIR_TYPE[dir] && pg.data.type !== undefined && pg.data.type !== DIR_TYPE[dir]) {
|
|
34
|
+
mechanical.push({ rule: 'type-dir-mismatch', path: pg.relPath, detail: `type "${pg.data.type}" in ${dir}/ (expected "${DIR_TYPE[dir]}")` })
|
|
35
|
+
}
|
|
23
36
|
for (const target of extractWikilinks(pg.body)) {
|
|
24
37
|
if (target.startsWith('raw/')) {
|
|
25
38
|
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}` })
|
|
@@ -34,8 +47,15 @@ export async function lintKb(kbRoot, { fix = false } = {}) {
|
|
|
34
47
|
if (pg.data.status !== undefined && !PAGE_STATUSES.includes(pg.data.status)) {
|
|
35
48
|
mechanical.push({ rule: 'invalid-status', path: pg.relPath, detail: `status "${pg.data.status}" (expected ${PAGE_STATUSES.join(' | ')})` })
|
|
36
49
|
}
|
|
37
|
-
if (pg.data.superseded_by !== undefined
|
|
38
|
-
|
|
50
|
+
if (pg.data.superseded_by !== undefined) {
|
|
51
|
+
const supTarget = String(pg.data.superseded_by)
|
|
52
|
+
// A page cannot be its own replacement: superseded_by == self invalidates the page
|
|
53
|
+
// with no valid successor (checked before target-missing, since the self id exists).
|
|
54
|
+
if (supTarget === pg.relPath.replace(/\.md$/, '')) {
|
|
55
|
+
mechanical.push({ rule: 'self-supersede', path: pg.relPath, detail: 'superseded_by points at itself' })
|
|
56
|
+
} else if (!ids.has(supTarget)) {
|
|
57
|
+
mechanical.push({ rule: 'superseded-target-missing', path: pg.relPath, detail: `superseded_by -> ${pg.data.superseded_by}` })
|
|
58
|
+
}
|
|
39
59
|
}
|
|
40
60
|
if (pg.data.relations !== undefined && !Array.isArray(pg.data.relations)) {
|
|
41
61
|
mechanical.push({ rule: 'invalid-relation-entry', path: pg.relPath, detail: 'relations must be a YAML list' })
|
package/src/llm-config.mjs
CHANGED
|
@@ -23,12 +23,36 @@ function resolveProviders(fileCfg) {
|
|
|
23
23
|
return null
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// The embeddingModel a credential-less (local) config would use: the flat top-level
|
|
27
|
+
// form wins, else the first provider IN PRIORITY ORDER that declares one — the same
|
|
28
|
+
// ordering resolveProviders uses, so the chat and embed paths can't disagree on which
|
|
29
|
+
// provider wins. Only file-configured providers are scanned (BUILTIN declares none).
|
|
30
|
+
function resolveEmbeddingModel(fileCfg) {
|
|
31
|
+
if (typeof fileCfg.embeddingModel === 'string') return fileCfg.embeddingModel
|
|
32
|
+
const { priority, providers } = fileCfg
|
|
33
|
+
if (!providers) return null
|
|
34
|
+
for (const name of priority ?? Object.keys(providers)) {
|
|
35
|
+
const em = providers[name]?.embeddingModel
|
|
36
|
+
if (em) return em
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read ~/.llm-wiki/config.json once per entry point. redactContents: this file holds
|
|
42
|
+
// API keys — a corrupt-JSON error must not echo a fragment of it to the terminal.
|
|
43
|
+
function readGlobalConfig() {
|
|
27
44
|
const dir = process.env.LLM_WIKI_CONFIG_DIR ?? path.join(os.homedir(), '.llm-wiki')
|
|
28
45
|
const globalFile = path.join(dir, 'config.json')
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
return fs.existsSync(globalFile) ? readJsonFile(globalFile, { redactContents: true }) : {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadLlmConfig(kbRoot) {
|
|
50
|
+
return loadChatConfig(kbRoot, readGlobalConfig())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve a chat-complete config from an already-read global fileCfg (+ the KB's
|
|
54
|
+
// optional llm override). Returns null unless baseURL/apiKey/model are all present.
|
|
55
|
+
function loadChatConfig(kbRoot, fileCfg) {
|
|
32
56
|
// flat form: explicit custom endpoint wins outright
|
|
33
57
|
let cfg = (fileCfg.baseURL && fileCfg.apiKey && fileCfg.model)
|
|
34
58
|
? { baseURL: fileCfg.baseURL, apiKey: fileCfg.apiKey, model: fileCfg.model, ...(fileCfg.embeddingModel ? { embeddingModel: fileCfg.embeddingModel } : {}) }
|
|
@@ -72,14 +96,10 @@ export function loadLlmConfig(kbRoot) {
|
|
|
72
96
|
// call the API, so it resolves only when loadLlmConfig does. loadLlmConfig's own
|
|
73
97
|
// (chat-complete-or-null) contract is unchanged.
|
|
74
98
|
export function loadEmbedConfig(kbRoot) {
|
|
75
|
-
const
|
|
99
|
+
const fileCfg = readGlobalConfig()
|
|
100
|
+
const chat = loadChatConfig(kbRoot, fileCfg)
|
|
76
101
|
if (chat?.embeddingModel) return chat
|
|
77
|
-
const
|
|
78
|
-
const globalFile = path.join(dir, 'config.json')
|
|
79
|
-
if (!fs.existsSync(globalFile)) return null
|
|
80
|
-
const fileCfg = readJsonFile(globalFile, { redactContents: true })
|
|
81
|
-
const em = fileCfg.embeddingModel
|
|
82
|
-
?? Object.values(fileCfg.providers ?? {}).map(p => p?.embeddingModel).find(Boolean)
|
|
102
|
+
const em = resolveEmbeddingModel(fileCfg)
|
|
83
103
|
return (typeof em === 'string' && em.startsWith('local:')) ? { embeddingModel: em } : null
|
|
84
104
|
}
|
|
85
105
|
|
package/src/local-embed.mjs
CHANGED
|
@@ -16,9 +16,20 @@ export function stripLocalPrefix(m) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
// A missing optional dependency surfaces as ERR_MODULE_NOT_FOUND — turn it into an
|
|
19
|
-
// actionable instruction instead of an opaque stack.
|
|
19
|
+
// actionable instruction instead of an opaque stack. Only map it when the ABSENT
|
|
20
|
+
// module is transformers.js itself: the same code fires when transformers.js IS
|
|
21
|
+
// installed but one of its (transitive/native) deps fails to resolve, and telling the
|
|
22
|
+
// user to re-install transformers then hides the real missing module. Pass those
|
|
23
|
+
// through so their message names the actual culprit.
|
|
20
24
|
export function friendlyImportError(err) {
|
|
21
|
-
if (err?.code
|
|
25
|
+
if (err?.code !== 'ERR_MODULE_NOT_FOUND') return err
|
|
26
|
+
// Gate on the MISSING package — the quoted name after "Cannot find package/module" —
|
|
27
|
+
// NOT a substring of the whole message. A transitive failure names its real culprit
|
|
28
|
+
// in the quotes (e.g. 'onnxruntime-node') yet still mentions "@huggingface/transformers"
|
|
29
|
+
// in the "imported from <path>" tail, so a whole-message test mis-maps it. Rewrite only
|
|
30
|
+
// when the absent module IS transformers.js (the package root or a subpath import of it).
|
|
31
|
+
const missing = /Cannot find (?:package|module) '([^']+)'/.exec(err.message ?? '')?.[1]
|
|
32
|
+
if (missing && /^@huggingface\/transformers(\/|$)/.test(missing)) {
|
|
22
33
|
return new Error('local embedding needs @huggingface/transformers; run: npm i @huggingface/transformers')
|
|
23
34
|
}
|
|
24
35
|
return err
|
package/src/scanner.mjs
CHANGED
|
@@ -150,6 +150,12 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
|
|
|
150
150
|
const diff = diffManifest(loadManifest(kbRoot), files.map(f => ({ rel: f.rel, hash: f.hash })))
|
|
151
151
|
const toCompileSet = new Set([...diff.added, ...diff.changed].map(e => e.rel))
|
|
152
152
|
const toCompile = files.filter(f => toCompileSet.has(f.rel) && !exactDups.has(f.rel))
|
|
153
|
+
// Exact duplicates are excluded from the compile batches (toCompile), so they must not
|
|
154
|
+
// inflate the reported added/changed counts either — a deduped file is never written to
|
|
155
|
+
// the manifest, so it would otherwise read as a permanent "+1 to convert" that `convert`
|
|
156
|
+
// never clears and that contradicts the "0 batches" plan. Dups stay in `duplicates.exact`.
|
|
157
|
+
const incAdded = diff.added.filter(e => !exactDups.has(e.rel)).length
|
|
158
|
+
const incChanged = diff.changed.filter(e => !exactDups.has(e.rel)).length
|
|
153
159
|
const batches = []
|
|
154
160
|
for (let i = 0; i < toCompile.length; i += cfg.batchSize) {
|
|
155
161
|
batches.push(toCompile.slice(i, i + cfg.batchSize).map(f => f.rel))
|
|
@@ -165,7 +171,7 @@ export async function scanSource(srcDir, kbRoot, { exclude = [], persist = true,
|
|
|
165
171
|
files: files.map(({ _sig, ...f }) => f),
|
|
166
172
|
skipped,
|
|
167
173
|
duplicates: { exact, near },
|
|
168
|
-
incremental: { added:
|
|
174
|
+
incremental: { added: incAdded, changed: incChanged, removed: diff.removed.length, unchanged: diff.unchanged.length },
|
|
169
175
|
domainMixture: detectDomainMixture(files, kbRoot),
|
|
170
176
|
batches,
|
|
171
177
|
estimate,
|