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 +1 -1
- package/src/commands/index.ts +298 -34
- package/src/db/client.ts +14 -1
- package/src/repositories/brain-repo.ts +10 -2
- package/src/settings.ts +51 -2
package/package.json
CHANGED
package/src/commands/index.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
567
|
+
const { answer, ok } = await generateAnswerWithStream(question, sections, stats, settings.llm);
|
|
563
568
|
|
|
564
|
-
|
|
565
|
-
|
|
569
|
+
if (!ok) {
|
|
570
|
+
// If streaming failed, answer contains the error message
|
|
571
|
+
console.log(answer);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
566
574
|
|
|
567
|
-
|
|
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
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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 —
|
|
1756
|
-
//
|
|
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
|
|
1771
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
1881
|
+
}
|
|
1787
1882
|
}
|
|
1788
1883
|
|
|
1789
|
-
// Combine scores
|
|
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
|
-
//
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
266
|
+
const trimmed = val?.trim();
|
|
267
|
+
return trimmed || fallback;
|
|
219
268
|
}
|
|
220
269
|
|
|
221
270
|
function numOr(val: number | string | undefined, fallback: number): number {
|