ex-brain 0.2.4 → 0.2.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/index.ts +206 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ex-brain",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "CLI personal knowledge base powered by seekdb",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -544,10 +544,14 @@ Examples:
544
544
  }
545
545
 
546
546
  // Collect multi-layer context (primary + raw data + linked pages scored by relevance)
547
- progress.update(`Loading pages, raw documents, and linked content...`);
548
547
  // ~100KB char budget ≈ 25K tokens, safe for most models
549
548
  const MAX_CONTEXT_CHARS = 100_000;
550
- const { sections, totalChars, stats } = await collectContextForLLM(repo, topHits, question, MAX_CONTEXT_CHARS);
549
+ const ctxStart = Date.now();
550
+ progress.update(`Loading page content...`);
551
+ const { sections, totalChars, stats } = await collectContextForLLM(repo, topHits, question, MAX_CONTEXT_CHARS, (stage) => {
552
+ progress.update(`Loading ${stage}...`);
553
+ });
554
+ const ctxDuration = formatDuration(Date.now() - ctxStart);
551
555
 
552
556
  if (sections.length === 0) {
553
557
  progress.stop();
@@ -556,16 +560,18 @@ Examples:
556
560
  return;
557
561
  }
558
562
 
559
- progress.update(`Generating answer from ${stats.primaryPages} page(s), ${stats.rawDocs} raw doc(s), ${stats.linkedPages} linked page(s)...`);
563
+ progress.succeed(`Loaded ${stats.primaryPages} page(s), ${stats.rawDocs} raw doc(s), ${stats.linkedPages} linked page(s) (${ctxDuration})`);
560
564
  const startTime = Date.now();
561
565
 
562
- const answer = await generateAnswerWithContext(question, sections, stats, settings.llm);
566
+ const { answer, ok } = await generateAnswerWithStream(question, sections, stats, settings.llm);
563
567
 
564
- const duration = formatDuration(Date.now() - startTime);
565
- progress.succeed(`Answer generated (${duration}, context: ${(totalChars / 1024).toFixed(1)}KB)`);
568
+ if (!ok) {
569
+ // If streaming failed, answer contains the error message
570
+ console.log(answer);
571
+ return;
572
+ }
566
573
 
567
- // Output answer as markdown
568
- console.log("\n" + answer);
574
+ const duration = formatDuration(Date.now() - startTime);
569
575
 
570
576
  // Show sources breakdown
571
577
  console.log("\n---\n**Sources:**\n");
@@ -1668,6 +1674,7 @@ async function collectContextForLLM(
1668
1674
  hits: Array<{ slug: string; title: string; score: number }>,
1669
1675
  question: string,
1670
1676
  maxChars: number,
1677
+ onProgress?: (stage: string) => void,
1671
1678
  ): Promise<{ sections: ContextSection[]; totalChars: number; stats: ContextStats }> {
1672
1679
  const sections: ContextSection[] = [];
1673
1680
  let totalChars = 0;
@@ -1699,10 +1706,15 @@ async function collectContextForLLM(
1699
1706
  return false;
1700
1707
  }
1701
1708
 
1709
+ // Cache pages fetched in Layer 1 to avoid redundant DB calls in Layer 3
1710
+ const pageCache = new Map<string, NonNullable<Awaited<ReturnType<typeof repo.getPage>>>>();
1711
+
1702
1712
  // Layer 1: Primary pages (compiledTruth + timeline)
1713
+ onProgress?.('page content');
1703
1714
  for (const hit of hits) {
1704
1715
  const page = await repo.getPage(hit.slug);
1705
1716
  if (!page) continue;
1717
+ pageCache.set(hit.slug, page);
1706
1718
 
1707
1719
  const parts: string[] = [];
1708
1720
  if (page.compiledTruth?.trim()) {
@@ -1726,6 +1738,7 @@ async function collectContextForLLM(
1726
1738
  }
1727
1739
 
1728
1740
  // Layer 2: Raw data (original documents)
1741
+ onProgress?.('raw documents');
1729
1742
  for (const hit of hits) {
1730
1743
  try {
1731
1744
  const rawRows = await repo.readRaw(hit.slug) as Array<{ source: string; data: unknown; fetchedAt?: string }>;
@@ -1752,8 +1765,9 @@ async function collectContextForLLM(
1752
1765
  }
1753
1766
  }
1754
1767
 
1755
- // Layer 3: Linked pages — SEMANTICALLY SCORED against the question
1756
- // Only include linked pages that are actually relevant to what the user asked.
1768
+ // Layer 3: Linked pages — score using cached data + keyword matching
1769
+ // No second repo.query() call needed reuse hits scores + keyword fallback
1770
+ onProgress?.('linked pages');
1757
1771
  const allLinkedSlugs = new Set<string>();
1758
1772
  for (const hit of hits) {
1759
1773
  try {
@@ -1767,26 +1781,27 @@ async function collectContextForLLM(
1767
1781
  }
1768
1782
 
1769
1783
  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
1784
+ // Score: use semantic scores from initial hits (already cached), keyword for rest
1785
+ const semanticScoreMap = new Map(hits.map(h => [h.slug, h.score]));
1777
1786
  const keywordScores = new Map<string, number>();
1778
1787
  for (const linkedSlug of allLinkedSlugs) {
1779
1788
  if (semanticScoreMap.has(linkedSlug)) continue;
1780
- try {
1789
+ // Use cached page if available, only fetch if not in cache
1790
+ const cached = pageCache.get(linkedSlug);
1791
+ if (cached) {
1792
+ const text = `${cached.title} ${cached.compiledTruth}`.slice(0, 2000);
1793
+ keywordScores.set(linkedSlug, computeKeywordRelevance(text, question));
1794
+ } else {
1781
1795
  const page = await repo.getPage(linkedSlug);
1782
1796
  if (page) {
1797
+ pageCache.set(linkedSlug, page);
1783
1798
  const text = `${page.title} ${page.compiledTruth}`.slice(0, 2000);
1784
1799
  keywordScores.set(linkedSlug, computeKeywordRelevance(text, question));
1785
1800
  }
1786
- } catch { /* ignore */ }
1801
+ }
1787
1802
  }
1788
1803
 
1789
- // Combine scores: semantic first, then keyword fallback
1804
+ // Combine scores
1790
1805
  const scoredLinked = [...allLinkedSlugs].map(slug => ({
1791
1806
  slug,
1792
1807
  score: semanticScoreMap.get(slug) ?? keywordScores.get(slug) ?? 0,
@@ -1798,11 +1813,11 @@ async function collectContextForLLM(
1798
1813
  .filter(s => s.score >= MIN_LINKED_SCORE)
1799
1814
  .sort((a, b) => b.score - a.score);
1800
1815
 
1801
- // Fetch content for relevant linked pages (respecting budget)
1816
+ // Add linked pages (already cached in pageCache, no extra fetch needed)
1802
1817
  for (const linked of relevantLinked) {
1803
1818
  if (totalChars >= maxChars) break;
1804
1819
 
1805
- const linkedPage = await repo.getPage(linked.slug);
1820
+ const linkedPage = pageCache.get(linked.slug);
1806
1821
  if (!linkedPage || !linkedPage.compiledTruth?.trim()) continue;
1807
1822
 
1808
1823
  const remaining = maxChars - totalChars;
@@ -1879,6 +1894,175 @@ interface ContextStats {
1879
1894
  /**
1880
1895
  * Build LLM prompt from collected context sections and generate answer.
1881
1896
  */
1897
+ async function generateAnswerWithStream(
1898
+ question: string,
1899
+ sections: ContextSection[],
1900
+ stats: ContextStats,
1901
+ llm: ResolvedLLM,
1902
+ ): Promise<{ answer: string; ok: boolean }> {
1903
+ const apiKey = llm.apiKey || process.env[llm.apiKeyEnv] || "";
1904
+ if (!apiKey) {
1905
+ return { answer: "Error: LLM API key not configured.", ok: false };
1906
+ }
1907
+
1908
+ if (sections.length === 0) {
1909
+ return { answer: "知识库中没有找到相关内容。", ok: true };
1910
+ }
1911
+
1912
+ // Build context sections with clear labels
1913
+ const contextParts: string[] = [];
1914
+ let sectionIndex = 0;
1915
+
1916
+ // Group by type for cleaner output
1917
+ const primarySections = sections.filter(s => s.type === 'primary');
1918
+ const rawSections = sections.filter(s => s.type === 'raw_data');
1919
+ const linkedSections = sections.filter(s => s.type === 'linked');
1920
+
1921
+ function renderSections(group: ContextSection[], header: string) {
1922
+ if (group.length === 0) return;
1923
+ contextParts.push(`## ${header}\n`);
1924
+ for (const s of group) {
1925
+ sectionIndex++;
1926
+ contextParts.push(`### [${sectionIndex}] ${s.title} — ${s.label}\n**Slug:** ${s.slug}\n\n${s.content}\n`);
1927
+ }
1928
+ contextParts.push('');
1929
+ }
1930
+
1931
+ renderSections(primarySections, '页面正文');
1932
+ renderSections(rawSections, '原始文档');
1933
+ renderSections(linkedSections, '关联页面');
1934
+
1935
+ const context = contextParts.join('\n');
1936
+
1937
+ const prompt = `你是一个知识库助手,请根据提供的知识库内容回答问题。
1938
+
1939
+ ## 问题
1940
+ ${question}
1941
+
1942
+ ## 知识库内容
1943
+
1944
+ ${context}
1945
+
1946
+ ## 回答要求
1947
+ - 仅基于提供的知识库内容回答,不要编造信息
1948
+ - 如果知识库中没有相关信息,请明确说明
1949
+ - 引用来源时使用 [[slug|标题]] 的格式
1950
+ - 使用清晰的 markdown 格式
1951
+ - 如果涉及时间线信息,请在回答中体现
1952
+ - 区分哪些信息来自「页面正文」、哪些来自「原始文档」、哪些来自「关联页面」
1953
+ - 语言与提问保持一致(中文提问用中文回答,英文提问用英文回答)
1954
+
1955
+ ## 回答`;
1956
+
1957
+ // Disable thinking/reasoning mode to reduce latency
1958
+ const disableThinking: Record<string, unknown> = {};
1959
+ // OpenAI/compatible: extra_body for thinking disable
1960
+ // DeepSeek: use extra_body to disable thinking
1961
+ // Many providers ignore unknown fields, so this is safe to always include
1962
+ const extraBody: Record<string, unknown> = {
1963
+ thinking: { type: "disabled" },
1964
+ };
1965
+
1966
+ try {
1967
+ const url = llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions";
1968
+
1969
+ // Show thinking indicator while waiting for first token
1970
+ process.stderr.write(`\x1b[35m💭\x1b[0m \x1b[2mConnecting to ${llm.model}...\x1b[0m\n`);
1971
+
1972
+ const resp = await fetch(
1973
+ url,
1974
+ {
1975
+ method: "POST",
1976
+ headers: {
1977
+ "Content-Type": "application/json",
1978
+ Authorization: `Bearer ${apiKey}`,
1979
+ },
1980
+ body: JSON.stringify({
1981
+ model: llm.model,
1982
+ stream: true,
1983
+ messages: [
1984
+ {
1985
+ role: "system",
1986
+ content: "你是一个专业的知识库助手,基于提供的知识库内容准确回答问题。引用来源时使用 [[slug|标题]] 格式。回答要条理清晰,区分信息来源。",
1987
+ },
1988
+ { role: "user", content: prompt },
1989
+ ],
1990
+ temperature: 0.3,
1991
+ max_tokens: 4096,
1992
+ ...disableThinking,
1993
+ extra_body: extraBody,
1994
+ // Also send thinking disable as top-level for providers that support it
1995
+ thinking: { type: "disabled" },
1996
+ }),
1997
+ // Abort if no response within 30s
1998
+ signal: AbortSignal.timeout(30_000),
1999
+ },
2000
+ );
2001
+
2002
+ if (!resp.ok) {
2003
+ const text = await resp.text();
2004
+ // Clear the thinking indicator line
2005
+ process.stderr.write("\r\x1b[K");
2006
+ return { answer: `Error: LLM API failed (${resp.status}): ${text.slice(0, 200)}`, ok: false };
2007
+ }
2008
+
2009
+ if (!resp.body) {
2010
+ process.stderr.write("\r\x1b[K");
2011
+ return { answer: "Error: No response body from LLM API.", ok: false };
2012
+ }
2013
+
2014
+ // Clear thinking indicator, show streaming status
2015
+ process.stderr.write("\r\x1b[K");
2016
+ process.stderr.write(`\x1b[32m✦\x1b[0m \x1b[2mStreaming response...\x1b[0m\n`);
2017
+
2018
+ // Stream the response
2019
+ const reader = resp.body.getReader();
2020
+ const decoder = new TextDecoder();
2021
+ let fullAnswer = "";
2022
+ let buffer = "";
2023
+ let tokenCount = 0;
2024
+
2025
+ while (true) {
2026
+ const { done, value } = await reader.read();
2027
+ if (done) break;
2028
+
2029
+ buffer += decoder.decode(value, { stream: true });
2030
+ const lines = buffer.split("\n");
2031
+ // Keep the last incomplete line in buffer
2032
+ buffer = lines.pop() || "";
2033
+
2034
+ for (const line of lines) {
2035
+ const trimmed = line.trim();
2036
+ if (!trimmed || trimmed === "data: [DONE]") continue;
2037
+ if (!trimmed.startsWith("data: ")) continue;
2038
+
2039
+ try {
2040
+ const json = JSON.parse(trimmed.slice(6));
2041
+ const content = json.choices?.[0]?.delta?.content;
2042
+ if (content) {
2043
+ process.stdout.write(content);
2044
+ fullAnswer += content;
2045
+ tokenCount++;
2046
+ }
2047
+ } catch {
2048
+ // Skip malformed SSE data
2049
+ }
2050
+ }
2051
+ }
2052
+
2053
+ // Add a newline after streaming completes
2054
+ process.stdout.write("\n");
2055
+
2056
+ return { answer: fullAnswer || "(No answer generated)", ok: true };
2057
+ } catch (error) {
2058
+ const msg = error instanceof Error ? error.message : String(error);
2059
+ return { answer: `Error: ${msg}`, ok: false };
2060
+ }
2061
+ }
2062
+
2063
+ /**
2064
+ * @deprecated Use generateAnswerWithStream instead
2065
+ */
1882
2066
  async function generateAnswerWithContext(
1883
2067
  question: string,
1884
2068
  sections: ContextSection[],