@sdsrs/llm-wiki 0.9.1 → 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 +40 -0
- package/bin/llm-wiki.mjs +18 -7
- package/package.json +1 -1
- package/src/ask.mjs +9 -2
- package/src/connect.mjs +4 -0
- package/src/embed.mjs +5 -1
- 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/scanner.mjs +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
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
|
+
|
|
3
43
|
## 0.9.1 (2026-07-12)
|
|
4
44
|
|
|
5
45
|
Correctness and diagnostics for the 0.9.0 local-embedding path; no change to the
|
package/bin/llm-wiki.mjs
CHANGED
|
@@ -89,10 +89,16 @@ program.command('ask <question>')
|
|
|
89
89
|
.action(async (question, opts) => {
|
|
90
90
|
const k = Number.parseInt(opts.k, 10)
|
|
91
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) }
|
|
92
93
|
const r = await askKb(opts.kb, question, { k, retrieveOnly: opts.retrieveOnly })
|
|
93
94
|
if (opts.retrieveOnly) {
|
|
94
95
|
if (r.pages.length === 0) {
|
|
95
|
-
|
|
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)')
|
|
96
102
|
return
|
|
97
103
|
}
|
|
98
104
|
for (const h of r.pages) console.log(`${h.score.toFixed(3)} ${h.relPath} [${(h.sources ?? ['bm25']).join('+')}]`)
|
|
@@ -164,12 +170,17 @@ program.command('export')
|
|
|
164
170
|
})
|
|
165
171
|
|
|
166
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
|
|
167
178
|
|
|
168
179
|
graphCmd.command('path <from> <to>')
|
|
169
180
|
.description('shortest link chain between two page ids (either link direction)')
|
|
170
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
181
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
171
182
|
.action((from, to, opts) => {
|
|
172
|
-
const r = shortestPath(loadGraph(opts
|
|
183
|
+
const r = shortestPath(loadGraph(graphKb(opts)), from, to)
|
|
173
184
|
if (!r) { console.log(`no path between ${from} and ${to}`); return }
|
|
174
185
|
console.log(r.nodes[0])
|
|
175
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' : ''}`)
|
|
@@ -178,11 +189,11 @@ graphCmd.command('path <from> <to>')
|
|
|
178
189
|
graphCmd.command('neighbors <id>')
|
|
179
190
|
.description('pages within N hops (links counted in both directions)')
|
|
180
191
|
.option('-d, --depth <n>', 'expansion depth', '1')
|
|
181
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
192
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
182
193
|
.action((id, opts) => {
|
|
183
194
|
const depth = Number.parseInt(opts.depth, 10)
|
|
184
195
|
if (!Number.isFinite(depth) || depth < 1) { console.error(`invalid depth: ${opts.depth} (expected a positive integer)`); process.exit(1) }
|
|
185
|
-
const r = neighborhood(loadGraph(opts
|
|
196
|
+
const r = neighborhood(loadGraph(graphKb(opts)), id, depth)
|
|
186
197
|
if (!r.length) { console.log(`${id} has no linked neighbors`); return }
|
|
187
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' : ''}`)
|
|
188
199
|
})
|
|
@@ -190,11 +201,11 @@ graphCmd.command('neighbors <id>')
|
|
|
190
201
|
graphCmd.command('hubs')
|
|
191
202
|
.description('most-connected pages (degree ranking; raw files excluded)')
|
|
192
203
|
.option('--top <n>', 'how many to show', '10')
|
|
193
|
-
.option('--kb <dir>', 'knowledge base root'
|
|
204
|
+
.option('--kb <dir>', 'knowledge base root')
|
|
194
205
|
.action((opts) => {
|
|
195
206
|
const top = Number.parseInt(opts.top, 10)
|
|
196
207
|
if (!Number.isFinite(top) || top < 1) { console.error(`invalid --top: ${opts.top} (expected a positive integer)`); process.exit(1) }
|
|
197
|
-
for (const h of hubs(loadGraph(opts
|
|
208
|
+
for (const h of hubs(loadGraph(graphKb(opts)), { top })) {
|
|
198
209
|
console.log(`${String(h.degree).padStart(3)} ${h.id} (in ${h.in} / out ${h.out}) ${h.title}${h.status === 'invalidated' ? ' ⚠ invalidated' : ''}`)
|
|
199
210
|
}
|
|
200
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
|
@@ -120,7 +120,11 @@ export async function embedKb(kbRoot, { fetchImpl, retry, pipelineFactory } = {}
|
|
|
120
120
|
const injected = fetchImpl || pipelineFactory
|
|
121
121
|
const t = injected
|
|
122
122
|
? { fetchImpl, dispatcher: undefined, retry, pipelineFactory }
|
|
123
|
-
|
|
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 }
|
|
124
128
|
for (let i = 0; i < jobs.length; i += BATCH) {
|
|
125
129
|
const batch = jobs.slice(i, i + BATCH)
|
|
126
130
|
const vecs = await embedTexts(cfg, t, batch.map(j => j.text))
|
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/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,
|