scream-code 0.8.2 → 0.8.4

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/dist/main.mjs CHANGED
@@ -6,7 +6,7 @@ const __dirname = __cjsShimDirname(__filename);
6
6
  import "./suppress-sqlite-warning-C2VB0doZ.mjs";
7
7
  //#region src/main.ts
8
8
  try {
9
- (await import("./app-C4TYfyrt.mjs")).main();
9
+ (await import("./app-DYDC5U75.mjs")).main();
10
10
  } catch (error) {
11
11
  process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
12
12
  process.exit(1);
@@ -243,6 +243,22 @@ var KnowledgeStore = class {
243
243
  return this.embeddingEngine;
244
244
  }
245
245
  /**
246
+ * Proactively load the embedding model (downloads on first call).
247
+ * Call before ingestion so the user gets a clear download prompt instead
248
+ * of a mid-ingest failure.
249
+ */
250
+ async ensureEmbeddingReady() {
251
+ if (this.embeddingEngine === void 0) return {
252
+ ok: false,
253
+ reason: "engine not set"
254
+ };
255
+ const ok = await this.embeddingEngine.ensureReady();
256
+ return {
257
+ ok,
258
+ reason: ok ? void 0 : "fastembed init failed"
259
+ };
260
+ }
261
+ /**
246
262
  * Begin a write transaction. Use commitTransaction / rollbackTransaction
247
263
  * to close it. Used by ingest to keep chunks/events/entities/edges atomic —
248
264
  * a mid-ingest failure leaves no partial rows.
@@ -637,6 +653,42 @@ var KnowledgeStore = class {
637
653
  JOIN knowledge_event_entities ee ON ee.entity_id = en.id
638
654
  WHERE ee.event_id = ?`).all(eventId).map(rowToEntity);
639
655
  }
656
+ async listEntities(sourceId) {
657
+ await this.init();
658
+ if (this.db === void 0) return [];
659
+ const sql = sourceId ? `SELECT e.*, COUNT(ee.id) AS event_count
660
+ FROM knowledge_entities e
661
+ LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
662
+ WHERE e.source_id = ?
663
+ GROUP BY e.id
664
+ ORDER BY event_count DESC` : `SELECT e.*, COUNT(ee.id) AS event_count
665
+ FROM knowledge_entities e
666
+ LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
667
+ GROUP BY e.id
668
+ ORDER BY event_count DESC`;
669
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
670
+ ...rowToEntity(row),
671
+ eventCount: Number(row["event_count"] ?? 0)
672
+ }));
673
+ }
674
+ async listEvents(sourceId) {
675
+ await this.init();
676
+ if (this.db === void 0) return [];
677
+ const sql = sourceId ? "SELECT * FROM knowledge_events WHERE source_id = ? ORDER BY rank ASC" : "SELECT * FROM knowledge_events ORDER BY created_at DESC";
678
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map(rowToEvent);
679
+ }
680
+ async listEventEntities(sourceId) {
681
+ await this.init();
682
+ if (this.db === void 0) return [];
683
+ const sql = sourceId ? `SELECT ee.event_id, ee.entity_id
684
+ FROM knowledge_event_entities ee
685
+ JOIN knowledge_events e ON e.id = ee.event_id
686
+ WHERE e.source_id = ?` : "SELECT event_id, entity_id FROM knowledge_event_entities";
687
+ return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
688
+ eventId: asString(row["event_id"]),
689
+ entityId: asString(row["entity_id"])
690
+ }));
691
+ }
640
692
  async ftsSearchChunks(query, limit = 50) {
641
693
  await this.init();
642
694
  if (this.db === void 0) return [];
@@ -819,6 +871,13 @@ function buildFtsQuery(search) {
819
871
  const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
820
872
  const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
821
873
  /**
874
+ * Soft cap on chunk token count. BGE-small-zh-v1.5 (the fastembed model used
875
+ * here) has a 512-token sequence limit; chunks longer than that get truncated
876
+ * at the embedding layer, hurting retrieval quality. We split at ~480 to leave
877
+ * headroom for the heading line that gets prepended at embed time.
878
+ */
879
+ const MAX_CHUNK_TOKENS = 480;
880
+ /**
822
881
  * Split a markdown document into sections using heading_strict strategy:
823
882
  * each section begins at a heading and contains all body content until the
824
883
  * next heading. Consecutive headings collapse to the lowest-level one as the
@@ -837,18 +896,25 @@ function chunkMarkdown(content) {
837
896
  const flush = () => {
838
897
  const raw = buffer.join("\n").trim();
839
898
  const heading = currentHeading;
899
+ const level = currentLevel;
840
900
  buffer = [];
841
901
  if (raw.length === 0) return;
842
902
  const body = stripMarkdown(raw);
843
903
  if (body.length === 0 && heading === null) return;
844
- sections.push({
904
+ const subSections = splitLargeSection({
845
905
  heading,
846
- headingLevel: currentLevel,
906
+ headingLevel: level,
847
907
  content: body,
848
908
  rawContent: raw,
849
909
  rank
850
910
  });
851
- rank += 1;
911
+ for (const section of subSections) {
912
+ sections.push({
913
+ ...section,
914
+ rank
915
+ });
916
+ rank += 1;
917
+ }
852
918
  };
853
919
  for (const line of lines) {
854
920
  if (CODE_FENCE_RE.exec(line)) {
@@ -882,10 +948,11 @@ function stripMarkdown(text) {
882
948
  /**
883
949
  * Split a plain text file into chunks by blank-line paragraphs.
884
950
  * Each non-empty paragraph becomes one chunk with no heading.
885
- * Long paragraphs are split at sentence boundaries to avoid huge chunks.
951
+ * Long paragraphs are split at sentence boundaries to stay under the
952
+ * embedding model's token cap (same MAX_CHUNK_TOKENS as markdown).
886
953
  */
887
954
  function chunkText(content) {
888
- const MAX_PARA_TOKENS = 800;
955
+ const MAX_PARA_TOKENS = MAX_CHUNK_TOKENS;
889
956
  const MAX_PARA_CHARS = MAX_PARA_TOKENS * 4;
890
957
  const paragraphs = content.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
891
958
  const sections = [];
@@ -928,8 +995,144 @@ function chunkText(content) {
928
995
  function estimateTokens(text) {
929
996
  return Math.ceil(text.length / 4);
930
997
  }
998
+ /**
999
+ * Split a section that exceeds MAX_CHUNK_TOKENS into smaller sub-sections.
1000
+ * Strategy (mirrors SAG's `splitLargeSection`):
1001
+ * 1. Split into paragraphs by blank lines.
1002
+ * 2. Greedily pack paragraphs into chunks under the token cap.
1003
+ * 3. A paragraph that alone exceeds the cap is split at sentence boundaries;
1004
+ * a sentence that itself exceeds the cap is split by character limit.
1005
+ *
1006
+ * Sections under the cap are returned unchanged (single-element array).
1007
+ * The heading is preserved across all sub-sections; rank is reassigned by the
1008
+ * caller. rawContent is rebuilt from the sub-chunk's content paragraphs.
1009
+ */
1010
+ function splitLargeSection(section) {
1011
+ if (estimateTokens(section.content) <= MAX_CHUNK_TOKENS) return [section];
1012
+ const paragraphs = section.rawContent.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
1013
+ if (paragraphs.length <= 1) return splitBySentences(section, section.rawContent);
1014
+ const result = [];
1015
+ let buffer = [];
1016
+ let bufferTokens = 0;
1017
+ const flushBuffer = () => {
1018
+ if (buffer.length === 0) return;
1019
+ const raw = buffer.join("\n\n").trim();
1020
+ if (raw.length === 0) {
1021
+ buffer = [];
1022
+ bufferTokens = 0;
1023
+ return;
1024
+ }
1025
+ result.push({
1026
+ heading: section.heading,
1027
+ headingLevel: section.headingLevel,
1028
+ content: stripMarkdown(raw),
1029
+ rawContent: raw,
1030
+ rank: 0
1031
+ });
1032
+ buffer = [];
1033
+ bufferTokens = 0;
1034
+ };
1035
+ for (const para of paragraphs) {
1036
+ const paraTokens = estimateTokens(para);
1037
+ if (paraTokens > MAX_CHUNK_TOKENS) {
1038
+ flushBuffer();
1039
+ for (const frag of splitBySentences(section, para)) result.push(frag);
1040
+ continue;
1041
+ }
1042
+ if (buffer.length > 0 && bufferTokens + paraTokens > MAX_CHUNK_TOKENS) flushBuffer();
1043
+ buffer.push(para);
1044
+ bufferTokens += paraTokens;
1045
+ }
1046
+ flushBuffer();
1047
+ return result.length > 0 ? result : [section];
1048
+ }
1049
+ /**
1050
+ * Split a long text into sub-sections at sentence boundaries.
1051
+ * Falls back to a hard character cut when a single sentence exceeds the cap.
1052
+ */
1053
+ function splitBySentences(section, text) {
1054
+ const sentences = splitSentences(text);
1055
+ const result = [];
1056
+ let buffer = "";
1057
+ let bufferTokens = 0;
1058
+ const flush = () => {
1059
+ const trimmed = buffer.trim();
1060
+ if (trimmed.length === 0) {
1061
+ buffer = "";
1062
+ bufferTokens = 0;
1063
+ return;
1064
+ }
1065
+ result.push({
1066
+ heading: section.heading,
1067
+ headingLevel: section.headingLevel,
1068
+ content: stripMarkdown(trimmed),
1069
+ rawContent: trimmed,
1070
+ rank: 0
1071
+ });
1072
+ buffer = "";
1073
+ bufferTokens = 0;
1074
+ };
1075
+ for (const sentence of sentences) {
1076
+ const sentTokens = estimateTokens(sentence);
1077
+ if (sentTokens > MAX_CHUNK_TOKENS) {
1078
+ flush();
1079
+ for (const frag of splitByCharLimit(sentence)) result.push({
1080
+ heading: section.heading,
1081
+ headingLevel: section.headingLevel,
1082
+ content: stripMarkdown(frag),
1083
+ rawContent: frag,
1084
+ rank: 0
1085
+ });
1086
+ continue;
1087
+ }
1088
+ if (buffer.length > 0 && bufferTokens + sentTokens > MAX_CHUNK_TOKENS) flush();
1089
+ buffer = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
1090
+ bufferTokens += sentTokens;
1091
+ }
1092
+ flush();
1093
+ return result.length > 0 ? result : [{
1094
+ heading: section.heading,
1095
+ headingLevel: section.headingLevel,
1096
+ content: section.content,
1097
+ rawContent: section.rawContent,
1098
+ rank: 0
1099
+ }];
1100
+ }
1101
+ /** Split text into sentences using CJK + ASCII punctuation. */
1102
+ function splitSentences(text) {
1103
+ return text.match(/[^。!?!?.\n]+[。!?!?.\n]+|[^。!?!?.\n]+$/g) ?? [text];
1104
+ }
1105
+ /** Hard character cut for pathological single sentences longer than the cap. */
1106
+ function splitByCharLimit(text) {
1107
+ const maxChars = MAX_CHUNK_TOKENS * 4;
1108
+ const chunks = [];
1109
+ let remaining = text.trim();
1110
+ while (remaining.length > maxChars) {
1111
+ chunks.push(remaining.slice(0, maxChars));
1112
+ remaining = remaining.slice(maxChars).trimStart();
1113
+ }
1114
+ if (remaining.length > 0) chunks.push(remaining);
1115
+ return chunks;
1116
+ }
931
1117
  //#endregion
932
1118
  //#region ../../packages/knowledge/src/extractor.ts
1119
+ /** Max LLM call attempts for extraction — fails fall back to a heading-derived event. */
1120
+ const EXTRACTION_MAX_ATTEMPTS = 3;
1121
+ /** Base delay (ms) for exponential backoff: 100, 200, 400. */
1122
+ const EXTRACTION_BACKOFF_BASE_MS = 100;
1123
+ function backoffDelay(attempt) {
1124
+ return EXTRACTION_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1);
1125
+ }
1126
+ async function callLlmWithBackoff(llm, systemPrompt, userPrompt) {
1127
+ let lastError;
1128
+ for (let attempt = 1; attempt <= EXTRACTION_MAX_ATTEMPTS; attempt += 1) try {
1129
+ return await llm.generate(systemPrompt, userPrompt);
1130
+ } catch (error) {
1131
+ lastError = error;
1132
+ if (attempt < EXTRACTION_MAX_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
1133
+ }
1134
+ throw lastError;
1135
+ }
933
1136
  const ENTITY_TYPES = [
934
1137
  "person",
935
1138
  "organization",
@@ -1104,11 +1307,12 @@ function fallbackEvent(chunk) {
1104
1307
  }
1105
1308
  /**
1106
1309
  * Run extraction on a single chunk via the LLM caller.
1107
- * Returns the parsed event (with fallback on failure).
1310
+ * Retries with exponential backoff on transient failures; falls back to a
1311
+ * heading-derived event only after all attempts fail.
1108
1312
  */
1109
1313
  async function extractEventFromChunk(llm, chunk) {
1110
1314
  try {
1111
- return parseExtractionResponse(await llm.generate(EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt(chunk)), chunk);
1315
+ return parseExtractionResponse(await callLlmWithBackoff(llm, EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt(chunk)), chunk);
1112
1316
  } catch {
1113
1317
  return fallbackEvent(chunk);
1114
1318
  }
@@ -1188,7 +1392,8 @@ Only include entities that explicitly appear in the query. If no clear entities,
1188
1392
  }
1189
1393
  //#endregion
1190
1394
  //#region ../../packages/knowledge/src/ingest.ts
1191
- const LLM_CONCURRENCY = 5;
1395
+ /** LLM concurrency for extraction — kept low to avoid rate-limit bursts. */
1396
+ const LLM_CONCURRENCY = 3;
1192
1397
  const SUPPORTED_EXTENSIONS = new Set([
1193
1398
  ".md",
1194
1399
  ".markdown",
@@ -1252,9 +1457,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
1252
1457
  if (engine === void 0 || !engine.available) {
1253
1458
  onProgress?.({
1254
1459
  stage: "error",
1255
- message: "embedding engine unavailable"
1460
+ message: "向量模型未就绪"
1256
1461
  });
1257
- throw new Error("embedding engine unavailable — knowledge base requires fastembed");
1462
+ throw new Error("向量模型未就绪,请检查网络后重试");
1258
1463
  }
1259
1464
  const existing = await store.findSourceByFilePath(filePath);
1260
1465
  if (existing !== void 0) {
@@ -1262,7 +1467,7 @@ async function ingestFile(store, llm, filePath, onProgress) {
1262
1467
  stage: "error",
1263
1468
  message: `文件已摄入:${existing.name}(source id=${existing.id})`
1264
1469
  });
1265
- throw new Error(`file already ingested: ${filePath} (source ${existing.id})`);
1470
+ throw new Error(`文件已摄入过: ${filePath} (source ${existing.id})`);
1266
1471
  }
1267
1472
  onProgress?.({
1268
1473
  stage: "chunking",
@@ -1275,7 +1480,7 @@ async function ingestFile(store, llm, filePath, onProgress) {
1275
1480
  stage: "error",
1276
1481
  message: "文件无有效内容"
1277
1482
  });
1278
- throw new Error("file has no content to ingest");
1483
+ throw new Error("文件无有效内容可摄入");
1279
1484
  }
1280
1485
  store.beginTransaction();
1281
1486
  try {
@@ -1300,9 +1505,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
1300
1505
  if (chunkEmbeddings === null) {
1301
1506
  onProgress?.({
1302
1507
  stage: "error",
1303
- message: "chunk embedding failed"
1508
+ message: "chunk 向量嵌入失败"
1304
1509
  });
1305
- throw new Error("chunk embedding failed");
1510
+ throw new Error("chunk 向量嵌入失败");
1306
1511
  }
1307
1512
  const chunks = [];
1308
1513
  for (let i = 0; i < sections.length; i++) {
@@ -1351,9 +1556,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
1351
1556
  if (titleEmbeddings === null || contentEmbeddings === null) {
1352
1557
  onProgress?.({
1353
1558
  stage: "error",
1354
- message: "event embedding failed"
1559
+ message: "event 向量嵌入失败"
1355
1560
  });
1356
- throw new Error("event embedding failed");
1561
+ throw new Error("event 向量嵌入失败");
1357
1562
  }
1358
1563
  const events = [];
1359
1564
  for (let i = 0; i < extractedEvents.length; i++) {
@@ -1394,9 +1599,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
1394
1599
  if (uniqueEntities.length > 0 && entityEmbeddings === null) {
1395
1600
  onProgress?.({
1396
1601
  stage: "error",
1397
- message: "entity embedding failed"
1602
+ message: "entity 向量嵌入失败"
1398
1603
  });
1399
- throw new Error("entity embedding failed");
1604
+ throw new Error("entity 向量嵌入失败");
1400
1605
  }
1401
1606
  const entityIdByEntityKey = /* @__PURE__ */ new Map();
1402
1607
  for (let i = 0; i < uniqueEntities.length; i++) {
@@ -1431,9 +1636,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
1431
1636
  if (relationPairs.length > 0 && relationEmbeddings === null) {
1432
1637
  onProgress?.({
1433
1638
  stage: "error",
1434
- message: "relation embedding failed"
1639
+ message: "relation 向量嵌入失败"
1435
1640
  });
1436
- throw new Error("relation embedding failed");
1641
+ throw new Error("relation 向量嵌入失败");
1437
1642
  }
1438
1643
  for (let i = 0; i < relationPairs.length; i++) {
1439
1644
  const pair = relationPairs[i];
@@ -1479,7 +1684,7 @@ async function collectSupportedFiles(dirPath) {
1479
1684
  }
1480
1685
  async function ingestDirectory(store, llm, dirPath, onProgress) {
1481
1686
  const files = await collectSupportedFiles(dirPath);
1482
- if (files.length === 0) throw new Error(`no supported files (.md, .markdown, .txt) found in ${dirPath}`);
1687
+ if (files.length === 0) throw new Error(`目录下没有支持的文件 (.md, .markdown, .txt): ${dirPath}`);
1483
1688
  const errors = [];
1484
1689
  let succeeded = 0;
1485
1690
  let totalChunks = 0;
@@ -1534,6 +1739,28 @@ const EXPANDED_EVENT_LIMIT = 100;
1534
1739
  const COARSE_RANK_LIMIT = 50;
1535
1740
  const ENTITY_VECTOR_THRESHOLD = .7;
1536
1741
  const TITLE_VECTOR_THRESHOLD = .4;
1742
+ async function timed(onStep, step, detail, fn, payload) {
1743
+ const start = performance.now();
1744
+ try {
1745
+ const result = await fn();
1746
+ const traceStep = {
1747
+ step,
1748
+ detail,
1749
+ durationMs: Math.round((performance.now() - start) * 100) / 100,
1750
+ ...payload !== void 0 ? { payload: payload(result) } : {}
1751
+ };
1752
+ onStep?.(traceStep);
1753
+ return result;
1754
+ } catch (error) {
1755
+ const durationMs = Math.round((performance.now() - start) * 100) / 100;
1756
+ onStep?.({
1757
+ step,
1758
+ detail: `${detail} 失败:${error instanceof Error ? error.message : String(error)}`,
1759
+ durationMs
1760
+ });
1761
+ throw error;
1762
+ }
1763
+ }
1537
1764
  /**
1538
1765
  * Multi-hop retrieval:
1539
1766
  * 1. Vectorize query.
@@ -1545,62 +1772,155 @@ const TITLE_VECTOR_THRESHOLD = .4;
1545
1772
  * 6. LLM rerank — pick top-K most relevant.
1546
1773
  * 7. Return corresponding chunks (deduped by chunk_id) with scores and provenance.
1547
1774
  * If rerank yields fewer than topK chunks, backfill by direct chunk vector search.
1775
+ *
1776
+ * Optional `onStep` callback receives a trace step at each phase — used by the
1777
+ * KnowledgeLookup tool to expose retrieval diagnostics to the agent.
1548
1778
  */
1549
1779
  async function multiSearch(store, llm, query, options = {}) {
1780
+ return (await multiSearchWithTrace(store, llm, query, options)).results;
1781
+ }
1782
+ /** Like multiSearch but also returns the retrieval trace for diagnostics. */
1783
+ async function multiSearchWithTrace(store, llm, query, options = {}) {
1550
1784
  const topK = Math.min(options.topK ?? DEFAULT_TOP_K, MAX_TOP_K);
1785
+ const steps = [];
1786
+ const onStep = (step) => steps.push(step);
1787
+ const rerankedEventTitles = [];
1788
+ let fallbackReason = null;
1551
1789
  const engine = store.getEmbeddingEngine();
1552
- if (engine === void 0 || !engine.available) return ftsFallback(store, query, topK);
1553
- const queryEmbeddings = await engine.embedBatch([query]);
1554
- if (queryEmbeddings === null || queryEmbeddings.length === 0) return ftsFallback(store, query, topK);
1790
+ if (engine === void 0 || !engine.available) {
1791
+ fallbackReason = "embedding engine unavailable; used FTS5 keyword fallback";
1792
+ return {
1793
+ results: await ftsFallback(store, query, topK),
1794
+ trace: {
1795
+ steps,
1796
+ rerankedEventTitles,
1797
+ fallbackReason
1798
+ }
1799
+ };
1800
+ }
1801
+ const queryEmbeddings = await timed(onStep, "queryEmbedding", "把用户问题转成向量,用于召回相关事件和切片。", async () => engine.embedBatch([query]));
1802
+ if (queryEmbeddings === null || queryEmbeddings.length === 0) {
1803
+ fallbackReason = "query embedding returned null; used FTS5 fallback";
1804
+ return {
1805
+ results: await ftsFallback(store, query, topK),
1806
+ trace: {
1807
+ steps,
1808
+ rerankedEventTitles,
1809
+ fallbackReason
1810
+ }
1811
+ };
1812
+ }
1555
1813
  const queryVec = queryEmbeddings[0];
1556
- const recalledEntities = await recallEntities(store, llm, query, queryVec);
1814
+ const recalledEntities = await timed(onStep, "entityRecall", "LLM 抽 query 实体 + 名字精确匹配 + 向量召回。", () => recallEntities(store, llm, query, queryVec), (entities) => ({ count: entities.length }));
1557
1815
  const seedEventIds = /* @__PURE__ */ new Set();
1558
1816
  for (const entity of recalledEntities) {
1559
1817
  const events = await store.findEventsByEntity(entity.id);
1560
1818
  for (const event of events) seedEventIds.add(event.id);
1561
1819
  }
1562
- const titleMatches = await store.findEventsByTitleVector(queryVec, {
1820
+ const titleMatches = await timed(onStep, "seedEventsByTitle", "按查询向量在事件标题向量上召回 seed events。", () => store.findEventsByTitleVector(queryVec, {
1563
1821
  limit: SEED_EVENT_LIMIT,
1564
1822
  threshold: TITLE_VECTOR_THRESHOLD
1565
- });
1823
+ }), (matches) => ({ count: matches.length }));
1566
1824
  for (const { event } of titleMatches) seedEventIds.add(event.id);
1567
- const expandedEventIds = new Set(seedEventIds);
1568
- for (const eventId of seedEventIds) {
1569
- const entities = await store.findEntitiesByEvent(eventId);
1570
- for (const entity of entities) {
1571
- const neighborEvents = await store.findEventsByEntity(entity.id);
1572
- for (const neighbor of neighborEvents) {
1573
- expandedEventIds.add(neighbor.id);
1574
- if (expandedEventIds.size >= EXPANDED_EVENT_LIMIT) break;
1825
+ if (seedEventIds.size === 0) {
1826
+ fallbackReason = "no seed events; used direct chunk vector search";
1827
+ return {
1828
+ results: await chunkVectorFallback(store, queryVec, topK),
1829
+ trace: {
1830
+ steps,
1831
+ rerankedEventTitles,
1832
+ fallbackReason
1833
+ }
1834
+ };
1835
+ }
1836
+ const expandedEventIds = await timed(onStep, "bfsExpand", "从 seed events 沿实体关系 1 跳扩展候选事件。", async () => {
1837
+ const ids = new Set(seedEventIds);
1838
+ let capped = false;
1839
+ for (const eventId of seedEventIds) {
1840
+ const entities = await store.findEntitiesByEvent(eventId);
1841
+ for (const entity of entities) {
1842
+ const neighborEvents = await store.findEventsByEntity(entity.id);
1843
+ for (const neighbor of neighborEvents) {
1844
+ ids.add(neighbor.id);
1845
+ if (ids.size >= EXPANDED_EVENT_LIMIT) {
1846
+ capped = true;
1847
+ break;
1848
+ }
1849
+ }
1850
+ if (ids.size >= EXPANDED_EVENT_LIMIT) break;
1575
1851
  }
1576
- if (expandedEventIds.size >= EXPANDED_EVENT_LIMIT) break;
1852
+ if (ids.size >= EXPANDED_EVENT_LIMIT) break;
1577
1853
  }
1578
- if (expandedEventIds.size >= EXPANDED_EVENT_LIMIT) break;
1579
- }
1580
- const candidates = [];
1581
- for (const eventId of expandedEventIds) {
1582
- const event = await store.getEvent(eventId);
1583
- if (event === void 0) continue;
1584
- const vec = event.contentEmbedding;
1585
- const score = vec === null ? 0 : engine.cosineSimilarity(queryVec, vec);
1586
- candidates.push({
1587
- id: event.id,
1588
- title: event.title,
1589
- summary: event.summary ?? "",
1590
- score,
1591
- chunkId: event.chunkId
1592
- });
1854
+ return {
1855
+ ids,
1856
+ capped
1857
+ };
1858
+ }, (result) => ({
1859
+ expandedCount: result.ids.size,
1860
+ capped: result.capped
1861
+ }));
1862
+ const { candidates, topCandidates } = await timed(onStep, "coarseRank", "按 content 向量相似度对候选事件排序,取前 COARSE_RANK_LIMIT 个。", async () => {
1863
+ const cands = [];
1864
+ for (const eventId of expandedEventIds.ids) {
1865
+ const event = await store.getEvent(eventId);
1866
+ if (event === void 0) continue;
1867
+ const vec = event.contentEmbedding;
1868
+ const score = vec === null ? 0 : engine.cosineSimilarity(queryVec, vec);
1869
+ cands.push({
1870
+ id: event.id,
1871
+ title: event.title,
1872
+ summary: event.summary ?? "",
1873
+ score,
1874
+ chunkId: event.chunkId
1875
+ });
1876
+ }
1877
+ cands.sort((a, b) => b.score - a.score);
1878
+ return {
1879
+ candidates: cands,
1880
+ topCandidates: cands.slice(0, COARSE_RANK_LIMIT)
1881
+ };
1882
+ }, (result) => ({
1883
+ totalCandidates: result.candidates.length,
1884
+ kept: result.topCandidates.length,
1885
+ topScore: result.topCandidates[0]?.score ?? 0
1886
+ }));
1887
+ if (candidates.length === 0) {
1888
+ fallbackReason = "no graph-reachable events with content; used chunk vector fallback";
1889
+ return {
1890
+ results: await chunkVectorFallback(store, queryVec, topK),
1891
+ trace: {
1892
+ steps,
1893
+ rerankedEventTitles,
1894
+ fallbackReason
1895
+ }
1896
+ };
1593
1897
  }
1594
- if (candidates.length === 0) return chunkVectorFallback(store, queryVec, topK);
1595
- candidates.sort((a, b) => b.score - a.score);
1596
- const topCandidates = candidates.slice(0, COARSE_RANK_LIMIT);
1597
1898
  let rankedIds;
1598
- if (options.skipRerank === true || topCandidates.length <= topK) rankedIds = topCandidates.map((c) => c.id);
1599
- else rankedIds = await rerankEventsWithLlm(llm, query, topCandidates.map((c) => ({
1899
+ if (options.skipRerank === true) {
1900
+ rankedIds = topCandidates.map((c) => c.id);
1901
+ onStep({
1902
+ step: "rerank",
1903
+ detail: "跳过 LLM rerank(skipRerank=true),直接用 coarse rank 顺序。",
1904
+ durationMs: 0,
1905
+ payload: { count: rankedIds.length }
1906
+ });
1907
+ } else if (topCandidates.length <= topK) {
1908
+ rankedIds = topCandidates.map((c) => c.id);
1909
+ onStep({
1910
+ step: "rerank",
1911
+ detail: `候选数 ${topCandidates.length} ≤ topK ${topK},无需 LLM rerank。`,
1912
+ durationMs: 0,
1913
+ payload: { count: rankedIds.length }
1914
+ });
1915
+ } else rankedIds = await timed(onStep, "rerank", `LLM 从 ${topCandidates.length} 个候选中选最相关的 ${topK} 个。`, () => rerankEventsWithLlm(llm, query, topCandidates.map((c) => ({
1600
1916
  id: c.id,
1601
1917
  title: c.title,
1602
1918
  summary: c.summary
1603
- })), topK);
1919
+ })), topK), (ids) => ({ count: ids.length }));
1920
+ for (const id of rankedIds) {
1921
+ const c = topCandidates.find((x) => x.id === id);
1922
+ if (c !== void 0) rerankedEventTitles.push(c.title);
1923
+ }
1604
1924
  const seen = /* @__PURE__ */ new Set();
1605
1925
  const results = [];
1606
1926
  for (const eventId of rankedIds) {
@@ -1612,17 +1932,29 @@ async function multiSearch(store, llm, query, options = {}) {
1612
1932
  if (result !== void 0) results.push(result);
1613
1933
  if (results.length >= topK) break;
1614
1934
  }
1615
- if (results.length < topK) for (const candidate of topCandidates) {
1616
- if (results.length >= topK) break;
1617
- if (rankedIds.includes(candidate.id)) continue;
1618
- if (seen.has(candidate.chunkId)) continue;
1619
- seen.add(candidate.chunkId);
1620
- const result = await store.buildSearchResult(candidate.chunkId, candidate.score, candidate.id);
1621
- if (result !== void 0) results.push(result);
1935
+ if (results.length < topK) {
1936
+ let supplemented = 0;
1937
+ for (const candidate of topCandidates) {
1938
+ if (results.length >= topK) break;
1939
+ if (rankedIds.includes(candidate.id)) continue;
1940
+ if (seen.has(candidate.chunkId)) continue;
1941
+ seen.add(candidate.chunkId);
1942
+ const result = await store.buildSearchResult(candidate.chunkId, candidate.score, candidate.id);
1943
+ if (result !== void 0) {
1944
+ results.push(result);
1945
+ supplemented += 1;
1946
+ }
1947
+ }
1948
+ if (supplemented > 0) onStep({
1949
+ step: "supplementFromCoarse",
1950
+ detail: `rerank 结果不足,从 coarse rank 残余补 ${supplemented} 个。`,
1951
+ durationMs: 0,
1952
+ payload: { supplemented }
1953
+ });
1622
1954
  }
1623
1955
  if (results.length < topK) {
1624
1956
  const remaining = topK - results.length;
1625
- const backfill = await store.searchChunksByVector(queryVec, { limit: remaining * 2 });
1957
+ const backfill = await timed(onStep, "backfill", `rerank 结果不足,回退到 chunk 向量搜索补 ${remaining} 个。`, () => store.searchChunksByVector(queryVec, { limit: remaining * 2 }), (matches) => ({ backfillCandidates: matches.length }));
1626
1958
  for (const { chunk, score } of backfill) {
1627
1959
  if (results.length >= topK) break;
1628
1960
  if (seen.has(chunk.id)) continue;
@@ -1631,7 +1963,14 @@ async function multiSearch(store, llm, query, options = {}) {
1631
1963
  if (result !== void 0) results.push(result);
1632
1964
  }
1633
1965
  }
1634
- return results;
1966
+ return {
1967
+ results,
1968
+ trace: {
1969
+ steps,
1970
+ rerankedEventTitles,
1971
+ fallbackReason
1972
+ }
1973
+ };
1635
1974
  }
1636
1975
  /** Recall entities by name (LLM-extracted) and by vector similarity. */
1637
1976
  async function recallEntities(store, llm, query, queryVec) {
@@ -1671,4 +2010,4 @@ async function chunkVectorFallback(store, queryVec, topK) {
1671
2010
  return results;
1672
2011
  }
1673
2012
  //#endregion
1674
- export { parse as C, normalize as S, resolve as T, KnowledgeStore as _, ENTITY_TYPES as a, isAbsolute as b, extractEventFromChunk as c, parseExtractionResponse as d, rerankEventsWithLlm as f, stripMarkdown as g, estimateTokens as h, isSupportedFile as i, extractJsonFromText as l, chunkText as m, ingestDirectory as n, EXTRACTION_SYSTEM_PROMPT as o, chunkMarkdown as p, ingestFile as r, buildExtractionUserPrompt as s, multiSearch as t, extractQueryEntities as u, basename as v, relative as w, join as x, dirname as y };
2013
+ export { join as C, resolve as D, relative as E, isAbsolute as S, parse as T, splitLargeSection as _, isSupportedFile as a, basename as b, buildExtractionUserPrompt as c, extractQueryEntities as d, parseExtractionResponse as f, estimateTokens as g, chunkText as h, ingestFile as i, extractEventFromChunk as l, chunkMarkdown as m, multiSearchWithTrace as n, ENTITY_TYPES as o, rerankEventsWithLlm as p, ingestDirectory as r, EXTRACTION_SYSTEM_PROMPT as s, multiSearch as t, extractJsonFromText as u, stripMarkdown as v, normalize as w, dirname as x, KnowledgeStore as y };
@@ -3,5 +3,5 @@ import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';
3
3
  import { dirname as __cjsShimDirname } from 'node:path';
4
4
  const __filename = __cjsShimFileURLToPath(import.meta.url);
5
5
  const __dirname = __cjsShimDirname(__filename);
6
- import { t as multiSearch } from "./src-aqNCeRzD.mjs";
7
- export { multiSearch };
6
+ import { n as multiSearchWithTrace } from "./src-CptHxtUY.mjs";
7
+ export { multiSearchWithTrace };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scream-code",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "A terminal-native AI agent for builders",
5
5
  "license": "MIT",
6
6
  "author": "ScreamCli",