ex-brain 0.2.4 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ex-brain",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "CLI personal knowledge base powered by seekdb",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -34,6 +34,7 @@ import {
34
34
  subItem,
35
35
  keyValue,
36
36
  header,
37
+ separator,
37
38
  createSpinner,
38
39
  formatCount,
39
40
  type ProgressSpinner,
@@ -544,10 +545,14 @@ Examples:
544
545
  }
545
546
 
546
547
  // Collect multi-layer context (primary + raw data + linked pages scored by relevance)
547
- progress.update(`Loading pages, raw documents, and linked content...`);
548
548
  // ~100KB char budget ≈ 25K tokens, safe for most models
549
549
  const MAX_CONTEXT_CHARS = 100_000;
550
- const { sections, totalChars, stats } = await collectContextForLLM(repo, topHits, question, MAX_CONTEXT_CHARS);
550
+ const ctxStart = Date.now();
551
+ progress.update(`Loading page content...`);
552
+ const { sections, totalChars, stats } = await collectContextForLLM(repo, topHits, question, MAX_CONTEXT_CHARS, (stage) => {
553
+ progress.update(`Loading ${stage}...`);
554
+ });
555
+ const ctxDuration = formatDuration(Date.now() - ctxStart);
551
556
 
552
557
  if (sections.length === 0) {
553
558
  progress.stop();
@@ -556,16 +561,18 @@ Examples:
556
561
  return;
557
562
  }
558
563
 
559
- progress.update(`Generating answer from ${stats.primaryPages} page(s), ${stats.rawDocs} raw doc(s), ${stats.linkedPages} linked page(s)...`);
564
+ progress.succeed(`Loaded ${stats.primaryPages} page(s), ${stats.rawDocs} raw doc(s), ${stats.linkedPages} linked page(s) (${ctxDuration})`);
560
565
  const startTime = Date.now();
561
566
 
562
- const answer = await generateAnswerWithContext(question, sections, stats, settings.llm);
567
+ const { answer, ok } = await generateAnswerWithStream(question, sections, stats, settings.llm);
563
568
 
564
- const duration = formatDuration(Date.now() - startTime);
565
- progress.succeed(`Answer generated (${duration}, context: ${(totalChars / 1024).toFixed(1)}KB)`);
569
+ if (!ok) {
570
+ // If streaming failed, answer contains the error message
571
+ console.log(answer);
572
+ return;
573
+ }
566
574
 
567
- // Output answer as markdown
568
- console.log("\n" + answer);
575
+ const duration = formatDuration(Date.now() - startTime);
569
576
 
570
577
  // Show sources breakdown
571
578
  console.log("\n---\n**Sources:**\n");
@@ -1477,27 +1484,106 @@ Examples:
1477
1484
 
1478
1485
  program
1479
1486
  .command("init")
1480
- .description("initialize the ebrain database")
1487
+ .description("initialize ebrain: create config, database, and show setup guide")
1481
1488
  .addHelpText(
1482
1489
  "after",
1483
1490
  `
1484
1491
  Examples:
1485
1492
  ebrain init
1493
+ ebrain init --db ./my.db
1486
1494
  `,
1487
1495
  )
1488
1496
  .action(async () => {
1489
- await withRepo(program, async () => {
1490
- const settings = await loadSettings();
1491
- const dbPath = program.opts().db ?? settings.dbPath;
1492
-
1493
- success(`Database initialized`);
1494
- keyValue("Path", dbPath);
1495
-
1496
- print(program, {
1497
- ok: true,
1498
- dbPath,
1499
- });
1497
+ const jsonOut = isJson(program);
1498
+ const settings = await loadSettings();
1499
+ const cliDb = program.opts().db;
1500
+ const dbPath = cliDb ?? settings.dbPath;
1501
+
1502
+ if (!jsonOut) {
1503
+ header("ebrain init");
1504
+ }
1505
+
1506
+ // Step 1: Create settings.json if it doesn't exist
1507
+ const { createDefaultSettings } = await import("../settings");
1508
+ const settingsCreated = await createDefaultSettings();
1509
+
1510
+ if (!jsonOut) {
1511
+ if (settingsCreated) {
1512
+ success(`Created config: ${SETTINGS_PATH}`);
1513
+ } else {
1514
+ success(`Config already exists: ${SETTINGS_PATH}`);
1515
+ }
1516
+ }
1517
+
1518
+ // Step 2: Check or initialize database
1519
+ const dbExists = await fileExists(dbPath);
1520
+ let dbInitialized = false;
1521
+
1522
+ if (dbExists) {
1523
+ // Database already exists, skip connection attempt to avoid
1524
+ // noisy errors (e.g. embedding function key mismatch)
1525
+ if (!jsonOut) {
1526
+ success(`Database already exists: ${dbPath}`);
1527
+ }
1528
+ dbInitialized = true;
1529
+ } else {
1530
+ // Try to create it without collection — embedding config may not be ready
1531
+ try {
1532
+ const db = await BrainDb.connect(dbPath, settings, { skipCollection: true });
1533
+ await db.close();
1534
+ await new Promise((r) => setTimeout(r, 200));
1535
+ dbInitialized = true;
1536
+ if (!jsonOut) {
1537
+ success(`Database initialized: ${dbPath}`);
1538
+ }
1539
+ } catch {
1540
+ if (!jsonOut) {
1541
+ warning(`Database will be auto-created on first use`);
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ // Step 3: Show setup guide
1547
+ if (!jsonOut) {
1548
+ console.log("");
1549
+ separator();
1550
+ info("Quick Start Guide");
1551
+ console.log("");
1552
+
1553
+ subItem("1. Configure LLM (for AI queries):", 0);
1554
+ subItem(` Edit ${SETTINGS_PATH}`, 4);
1555
+ subItem(` Set llm.baseURL to your OpenAI-compatible API endpoint`, 4);
1556
+ subItem(` Set llm.apiKey or export DASHSCOPE_API_KEY`, 4);
1557
+ console.log("");
1558
+
1559
+ subItem("2. Add your first page:", 0);
1560
+ subItem(" echo '# Hello' | ebrain put hello --stdin", 4);
1561
+ console.log("");
1562
+
1563
+ subItem("3. Import a directory of markdown files:", 0);
1564
+ subItem(" ebrain import ./docs", 4);
1565
+ console.log("");
1566
+
1567
+ subItem("4. Query with AI:", 0);
1568
+ subItem(' ebrain query "What did we ship in Q4?" --llm', 4);
1569
+ console.log("");
1570
+
1571
+ subItem("5. Visualize your knowledge graph:", 0);
1572
+ subItem(" ebrain graph", 4);
1573
+ console.log("");
1574
+
1575
+ separator();
1576
+ }
1577
+
1578
+ print(program, {
1579
+ ok: true,
1580
+ settingsPath: SETTINGS_PATH,
1581
+ settingsCreated,
1582
+ dbPath,
1583
+ dbInitialized,
1500
1584
  });
1585
+
1586
+ process.exit(0);
1501
1587
  });
1502
1588
 
1503
1589
  program
@@ -1668,6 +1754,7 @@ async function collectContextForLLM(
1668
1754
  hits: Array<{ slug: string; title: string; score: number }>,
1669
1755
  question: string,
1670
1756
  maxChars: number,
1757
+ onProgress?: (stage: string) => void,
1671
1758
  ): Promise<{ sections: ContextSection[]; totalChars: number; stats: ContextStats }> {
1672
1759
  const sections: ContextSection[] = [];
1673
1760
  let totalChars = 0;
@@ -1699,10 +1786,15 @@ async function collectContextForLLM(
1699
1786
  return false;
1700
1787
  }
1701
1788
 
1789
+ // Cache pages fetched in Layer 1 to avoid redundant DB calls in Layer 3
1790
+ const pageCache = new Map<string, NonNullable<Awaited<ReturnType<typeof repo.getPage>>>>();
1791
+
1702
1792
  // Layer 1: Primary pages (compiledTruth + timeline)
1793
+ onProgress?.('page content');
1703
1794
  for (const hit of hits) {
1704
1795
  const page = await repo.getPage(hit.slug);
1705
1796
  if (!page) continue;
1797
+ pageCache.set(hit.slug, page);
1706
1798
 
1707
1799
  const parts: string[] = [];
1708
1800
  if (page.compiledTruth?.trim()) {
@@ -1726,6 +1818,7 @@ async function collectContextForLLM(
1726
1818
  }
1727
1819
 
1728
1820
  // Layer 2: Raw data (original documents)
1821
+ onProgress?.('raw documents');
1729
1822
  for (const hit of hits) {
1730
1823
  try {
1731
1824
  const rawRows = await repo.readRaw(hit.slug) as Array<{ source: string; data: unknown; fetchedAt?: string }>;
@@ -1752,8 +1845,9 @@ async function collectContextForLLM(
1752
1845
  }
1753
1846
  }
1754
1847
 
1755
- // Layer 3: Linked pages — SEMANTICALLY SCORED against the question
1756
- // Only include linked pages that are actually relevant to what the user asked.
1848
+ // Layer 3: Linked pages — score using cached data + keyword matching
1849
+ // No second repo.query() call needed reuse hits scores + keyword fallback
1850
+ onProgress?.('linked pages');
1757
1851
  const allLinkedSlugs = new Set<string>();
1758
1852
  for (const hit of hits) {
1759
1853
  try {
@@ -1767,26 +1861,27 @@ async function collectContextForLLM(
1767
1861
  }
1768
1862
 
1769
1863
  if (allLinkedSlugs.size > 0) {
1770
- // Score linked pages using broad semantic search.
1771
- // Query a wide set of pages, then intersect with linked slugs.
1772
- const broadLimit = Math.min(200, Math.max(50, allLinkedSlugs.size));
1773
- const broadResults = await repo.query(question, broadLimit);
1774
- const semanticScoreMap = new Map(broadResults.map(h => [h.slug, h.score]));
1775
-
1776
- // Keyword-based fallback scoring for linked pages without embedding scores
1864
+ // Score: use semantic scores from initial hits (already cached), keyword for rest
1865
+ const semanticScoreMap = new Map(hits.map(h => [h.slug, h.score]));
1777
1866
  const keywordScores = new Map<string, number>();
1778
1867
  for (const linkedSlug of allLinkedSlugs) {
1779
1868
  if (semanticScoreMap.has(linkedSlug)) continue;
1780
- try {
1869
+ // Use cached page if available, only fetch if not in cache
1870
+ const cached = pageCache.get(linkedSlug);
1871
+ if (cached) {
1872
+ const text = `${cached.title} ${cached.compiledTruth}`.slice(0, 2000);
1873
+ keywordScores.set(linkedSlug, computeKeywordRelevance(text, question));
1874
+ } else {
1781
1875
  const page = await repo.getPage(linkedSlug);
1782
1876
  if (page) {
1877
+ pageCache.set(linkedSlug, page);
1783
1878
  const text = `${page.title} ${page.compiledTruth}`.slice(0, 2000);
1784
1879
  keywordScores.set(linkedSlug, computeKeywordRelevance(text, question));
1785
1880
  }
1786
- } catch { /* ignore */ }
1881
+ }
1787
1882
  }
1788
1883
 
1789
- // Combine scores: semantic first, then keyword fallback
1884
+ // Combine scores
1790
1885
  const scoredLinked = [...allLinkedSlugs].map(slug => ({
1791
1886
  slug,
1792
1887
  score: semanticScoreMap.get(slug) ?? keywordScores.get(slug) ?? 0,
@@ -1798,11 +1893,11 @@ async function collectContextForLLM(
1798
1893
  .filter(s => s.score >= MIN_LINKED_SCORE)
1799
1894
  .sort((a, b) => b.score - a.score);
1800
1895
 
1801
- // Fetch content for relevant linked pages (respecting budget)
1896
+ // Add linked pages (already cached in pageCache, no extra fetch needed)
1802
1897
  for (const linked of relevantLinked) {
1803
1898
  if (totalChars >= maxChars) break;
1804
1899
 
1805
- const linkedPage = await repo.getPage(linked.slug);
1900
+ const linkedPage = pageCache.get(linked.slug);
1806
1901
  if (!linkedPage || !linkedPage.compiledTruth?.trim()) continue;
1807
1902
 
1808
1903
  const remaining = maxChars - totalChars;
@@ -1879,6 +1974,175 @@ interface ContextStats {
1879
1974
  /**
1880
1975
  * Build LLM prompt from collected context sections and generate answer.
1881
1976
  */
1977
+ async function generateAnswerWithStream(
1978
+ question: string,
1979
+ sections: ContextSection[],
1980
+ stats: ContextStats,
1981
+ llm: ResolvedLLM,
1982
+ ): Promise<{ answer: string; ok: boolean }> {
1983
+ const apiKey = llm.apiKey || process.env[llm.apiKeyEnv] || "";
1984
+ if (!apiKey) {
1985
+ return { answer: "Error: LLM API key not configured.", ok: false };
1986
+ }
1987
+
1988
+ if (sections.length === 0) {
1989
+ return { answer: "知识库中没有找到相关内容。", ok: true };
1990
+ }
1991
+
1992
+ // Build context sections with clear labels
1993
+ const contextParts: string[] = [];
1994
+ let sectionIndex = 0;
1995
+
1996
+ // Group by type for cleaner output
1997
+ const primarySections = sections.filter(s => s.type === 'primary');
1998
+ const rawSections = sections.filter(s => s.type === 'raw_data');
1999
+ const linkedSections = sections.filter(s => s.type === 'linked');
2000
+
2001
+ function renderSections(group: ContextSection[], header: string) {
2002
+ if (group.length === 0) return;
2003
+ contextParts.push(`## ${header}\n`);
2004
+ for (const s of group) {
2005
+ sectionIndex++;
2006
+ contextParts.push(`### [${sectionIndex}] ${s.title} — ${s.label}\n**Slug:** ${s.slug}\n\n${s.content}\n`);
2007
+ }
2008
+ contextParts.push('');
2009
+ }
2010
+
2011
+ renderSections(primarySections, '页面正文');
2012
+ renderSections(rawSections, '原始文档');
2013
+ renderSections(linkedSections, '关联页面');
2014
+
2015
+ const context = contextParts.join('\n');
2016
+
2017
+ const prompt = `你是一个知识库助手,请根据提供的知识库内容回答问题。
2018
+
2019
+ ## 问题
2020
+ ${question}
2021
+
2022
+ ## 知识库内容
2023
+
2024
+ ${context}
2025
+
2026
+ ## 回答要求
2027
+ - 仅基于提供的知识库内容回答,不要编造信息
2028
+ - 如果知识库中没有相关信息,请明确说明
2029
+ - 引用来源时使用 [[slug|标题]] 的格式
2030
+ - 使用清晰的 markdown 格式
2031
+ - 如果涉及时间线信息,请在回答中体现
2032
+ - 区分哪些信息来自「页面正文」、哪些来自「原始文档」、哪些来自「关联页面」
2033
+ - 语言与提问保持一致(中文提问用中文回答,英文提问用英文回答)
2034
+
2035
+ ## 回答`;
2036
+
2037
+ // Disable thinking/reasoning mode to reduce latency
2038
+ const disableThinking: Record<string, unknown> = {};
2039
+ // OpenAI/compatible: extra_body for thinking disable
2040
+ // DeepSeek: use extra_body to disable thinking
2041
+ // Many providers ignore unknown fields, so this is safe to always include
2042
+ const extraBody: Record<string, unknown> = {
2043
+ thinking: { type: "disabled" },
2044
+ };
2045
+
2046
+ try {
2047
+ const url = llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions";
2048
+
2049
+ // Show thinking indicator while waiting for first token
2050
+ process.stderr.write(`\x1b[35m💭\x1b[0m \x1b[2mConnecting to ${llm.model}...\x1b[0m\n`);
2051
+
2052
+ const resp = await fetch(
2053
+ url,
2054
+ {
2055
+ method: "POST",
2056
+ headers: {
2057
+ "Content-Type": "application/json",
2058
+ Authorization: `Bearer ${apiKey}`,
2059
+ },
2060
+ body: JSON.stringify({
2061
+ model: llm.model,
2062
+ stream: true,
2063
+ messages: [
2064
+ {
2065
+ role: "system",
2066
+ content: "你是一个专业的知识库助手,基于提供的知识库内容准确回答问题。引用来源时使用 [[slug|标题]] 格式。回答要条理清晰,区分信息来源。",
2067
+ },
2068
+ { role: "user", content: prompt },
2069
+ ],
2070
+ temperature: 0.3,
2071
+ max_tokens: 4096,
2072
+ ...disableThinking,
2073
+ extra_body: extraBody,
2074
+ // Also send thinking disable as top-level for providers that support it
2075
+ thinking: { type: "disabled" },
2076
+ }),
2077
+ // Abort if no response within 30s
2078
+ signal: AbortSignal.timeout(30_000),
2079
+ },
2080
+ );
2081
+
2082
+ if (!resp.ok) {
2083
+ const text = await resp.text();
2084
+ // Clear the thinking indicator line
2085
+ process.stderr.write("\r\x1b[K");
2086
+ return { answer: `Error: LLM API failed (${resp.status}): ${text.slice(0, 200)}`, ok: false };
2087
+ }
2088
+
2089
+ if (!resp.body) {
2090
+ process.stderr.write("\r\x1b[K");
2091
+ return { answer: "Error: No response body from LLM API.", ok: false };
2092
+ }
2093
+
2094
+ // Clear thinking indicator, show streaming status
2095
+ process.stderr.write("\r\x1b[K");
2096
+ process.stderr.write(`\x1b[32m✦\x1b[0m \x1b[2mStreaming response...\x1b[0m\n`);
2097
+
2098
+ // Stream the response
2099
+ const reader = resp.body.getReader();
2100
+ const decoder = new TextDecoder();
2101
+ let fullAnswer = "";
2102
+ let buffer = "";
2103
+ let tokenCount = 0;
2104
+
2105
+ while (true) {
2106
+ const { done, value } = await reader.read();
2107
+ if (done) break;
2108
+
2109
+ buffer += decoder.decode(value, { stream: true });
2110
+ const lines = buffer.split("\n");
2111
+ // Keep the last incomplete line in buffer
2112
+ buffer = lines.pop() || "";
2113
+
2114
+ for (const line of lines) {
2115
+ const trimmed = line.trim();
2116
+ if (!trimmed || trimmed === "data: [DONE]") continue;
2117
+ if (!trimmed.startsWith("data: ")) continue;
2118
+
2119
+ try {
2120
+ const json = JSON.parse(trimmed.slice(6));
2121
+ const content = json.choices?.[0]?.delta?.content;
2122
+ if (content) {
2123
+ process.stdout.write(content);
2124
+ fullAnswer += content;
2125
+ tokenCount++;
2126
+ }
2127
+ } catch {
2128
+ // Skip malformed SSE data
2129
+ }
2130
+ }
2131
+ }
2132
+
2133
+ // Add a newline after streaming completes
2134
+ process.stdout.write("\n");
2135
+
2136
+ return { answer: fullAnswer || "(No answer generated)", ok: true };
2137
+ } catch (error) {
2138
+ const msg = error instanceof Error ? error.message : String(error);
2139
+ return { answer: `Error: ${msg}`, ok: false };
2140
+ }
2141
+ }
2142
+
2143
+ /**
2144
+ * @deprecated Use generateAnswerWithStream instead
2145
+ */
1882
2146
  async function generateAnswerWithContext(
1883
2147
  question: string,
1884
2148
  sections: ContextSection[],
package/src/db/client.ts CHANGED
@@ -107,7 +107,11 @@ export class BrainDb {
107
107
  return new Promise((resolve) => setTimeout(resolve, ms));
108
108
  }
109
109
 
110
- static async connect(dbPath: string, settings?: ResolvedSettings): Promise<BrainDb> {
110
+ static async connect(
111
+ dbPath: string,
112
+ settings?: ResolvedSettings,
113
+ options?: { skipCollection?: boolean },
114
+ ): Promise<BrainDb> {
111
115
  try {
112
116
  const client = settings?.remote
113
117
  ? await BrainDb.openRemoteClient(settings.remote)
@@ -122,6 +126,15 @@ export class BrainDb {
122
126
  await client.execute(sql);
123
127
  }
124
128
 
129
+ // Skip collection creation for init (embedding config may not be ready)
130
+ if (options?.skipCollection) {
131
+ const db = new BrainDb(dbPath, client, null as unknown as Collection);
132
+ db._isConnected = true;
133
+ db._lastConnectedAt = new Date();
134
+ console.error("\x1b[32m[DB] Connected successfully\x1b[0m");
135
+ return db;
136
+ }
137
+
125
138
  const pagesCollection = await client.getOrCreateCollection({
126
139
  name: PAGES_COLLECTION,
127
140
  embeddingFunction: createBrainEmbeddingFunction(settings?.embed),
@@ -347,8 +347,13 @@ export class BrainRepository {
347
347
  metadatas: [meta],
348
348
  });
349
349
  } catch (error) {
350
+ const msg = error instanceof Error ? error.message : String(error);
351
+ // Dimension mismatch means the collection was created with a different
352
+ // embedding model. This is non-critical — pages still work, just no search.
353
+ if (msg.includes("Dimension mismatch")) {
354
+ return;
355
+ }
350
356
  const dbError = wrapDbError(error, "syncPageToSearch", { slug });
351
- logDbError(dbError);
352
357
  // Don't throw - sync failure shouldn't break the main flow
353
358
  console.warn(`[BrainRepo] syncPageToSearch failed for ${slug}: ${dbError.message}`);
354
359
  }
@@ -384,8 +389,11 @@ export class BrainRepository {
384
389
  metadatas: metas,
385
390
  });
386
391
  } catch (error) {
392
+ const msg = error instanceof Error ? error.message : String(error);
393
+ if (msg.includes("Dimension mismatch")) {
394
+ return;
395
+ }
387
396
  const dbError = wrapDbError(error, "syncPagesToSearch", { count: slugs.length });
388
- logDbError(dbError);
389
397
  // Don't throw - sync failure shouldn't break the main flow
390
398
  console.warn(`[BrainRepo] syncPagesToSearch failed: ${dbError.message}`);
391
399
  }
package/src/settings.ts CHANGED
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { fileExists, readTextFile } from "./markdown/io";
5
5
 
6
- const SETTINGS_DIR = join(homedir(), ".ebrain");
6
+ export const SETTINGS_DIR = join(homedir(), ".ebrain");
7
7
  export const SETTINGS_PATH = join(SETTINGS_DIR, "settings.json");
8
8
  export const DEFAULT_DB_PATH = resolve(SETTINGS_DIR, "data", "ebrain.db");
9
9
 
@@ -150,6 +150,54 @@ export async function readSettingsFile(): Promise<unknown | null> {
150
150
  }
151
151
  }
152
152
 
153
+ /**
154
+ * Generate a minimal settings.json if it doesn't already exist.
155
+ * Returns true if a new file was created.
156
+ */
157
+ export async function createDefaultSettings(): Promise<boolean> {
158
+ if (await fileExists(SETTINGS_PATH)) {
159
+ return false;
160
+ }
161
+
162
+ const { mkdirSync, writeFileSync } = await import("node:fs");
163
+ mkdirSync(SETTINGS_DIR, { recursive: true });
164
+
165
+ // All fields present but empty — user fills in their values
166
+ const defaults = {
167
+ db: {
168
+ path: "",
169
+ remote: {
170
+ host: "",
171
+ port: 0,
172
+ user: "",
173
+ password: "",
174
+ database: "",
175
+ tenant: "",
176
+ },
177
+ },
178
+ embed: {
179
+ provider: "hash",
180
+ baseURL: "",
181
+ model: "",
182
+ dimensions: 0,
183
+ apiKey: "",
184
+ apiKeyEnv: "",
185
+ },
186
+ llm: {
187
+ baseURL: "",
188
+ model: "",
189
+ apiKey: "",
190
+ apiKeyEnv: "",
191
+ },
192
+ extraction: {
193
+ confidenceThreshold: 0.7,
194
+ },
195
+ };
196
+
197
+ writeFileSync(SETTINGS_PATH, JSON.stringify(defaults, null, 2) + "\n", "utf-8");
198
+ return true;
199
+ }
200
+
153
201
  export function resolveSettings(parsed: z.infer<typeof SettingsSchema>): ResolvedSettings {
154
202
  const dbConf = parsed.db ?? {};
155
203
  const remoteConf = dbConf.remote ?? {};
@@ -215,7 +263,8 @@ function resolveExtraction(conf: { confidenceThreshold?: number }): ResolvedExtr
215
263
  // ---------------------------------------------------------------------------
216
264
 
217
265
  function nonEmpty(val: string | undefined, fallback: string): string {
218
- return val?.trim() ?? fallback;
266
+ const trimmed = val?.trim();
267
+ return trimmed || fallback;
219
268
  }
220
269
 
221
270
  function numOr(val: number | string | undefined, fallback: number): number {