recallx 1.0.0 → 1.0.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.
@@ -34,6 +34,7 @@ const workspaceInboxSource = {
34
34
  actorLabel: "RecallX",
35
35
  toolName: "recallx-system"
36
36
  };
37
+ const DEFAULT_WORKSPACE_SEMANTIC_FALLBACK_MODE = "strict_zero";
37
38
  function normalizeSearchText(value) {
38
39
  return (value ?? "").normalize("NFKC").toLowerCase();
39
40
  }
@@ -52,32 +53,140 @@ function createSearchFieldMatcher(query) {
52
53
  matchTerms: tokens.length ? tokens : [trimmedQuery]
53
54
  };
54
55
  }
55
- function collectMatchedFields(matcher, candidates) {
56
+ function collectSearchFieldSignals(matcher, candidates) {
56
57
  if (!matcher) {
57
- return [];
58
+ return {
59
+ matchedFields: [],
60
+ exactFields: [],
61
+ matchedTermCount: 0,
62
+ totalTermCount: 0
63
+ };
58
64
  }
59
- const matches = new Set();
65
+ const matchedFields = new Set();
66
+ const exactFields = new Set();
67
+ const matchedTerms = new Set();
60
68
  for (const candidate of candidates) {
61
69
  const haystack = normalizeSearchText(candidate.value);
62
70
  if (!haystack) {
63
71
  continue;
64
72
  }
65
- if (haystack.includes(matcher.trimmedQuery) || matcher.matchTerms.some((term) => haystack.includes(term))) {
66
- matches.add(candidate.field);
73
+ const exactMatch = haystack.includes(matcher.trimmedQuery);
74
+ const termMatches = matcher.matchTerms.filter((term) => haystack.includes(term));
75
+ if (!exactMatch && !termMatches.length) {
76
+ continue;
77
+ }
78
+ matchedFields.add(candidate.field);
79
+ if (exactMatch) {
80
+ exactFields.add(candidate.field);
81
+ }
82
+ for (const term of termMatches) {
83
+ matchedTerms.add(term);
84
+ }
85
+ }
86
+ return {
87
+ matchedFields: [...matchedFields],
88
+ exactFields: [...exactFields],
89
+ matchedTermCount: matchedTerms.size,
90
+ totalTermCount: matcher.matchTerms.length
91
+ };
92
+ }
93
+ function classifyNodeLexicalQuality(strategy, signals) {
94
+ if (strategy === "browse" || strategy === "semantic" || !signals.matchedFields.length) {
95
+ return "none";
96
+ }
97
+ if (strategy === "fallback_token") {
98
+ return "weak";
99
+ }
100
+ const strongExactFields = new Set(["title", "summary", "tags"]);
101
+ if (signals.exactFields.some((field) => strongExactFields.has(field))) {
102
+ return "strong";
103
+ }
104
+ const termCoverage = signals.totalTermCount > 0 ? signals.matchedTermCount / signals.totalTermCount : 0;
105
+ if (strategy === "fts" && termCoverage >= 0.6 && signals.matchedFields.some((field) => strongExactFields.has(field))) {
106
+ return "strong";
107
+ }
108
+ return "weak";
109
+ }
110
+ function classifyActivityLexicalQuality(strategy, signals) {
111
+ if (strategy === "browse" || strategy === "semantic" || !signals.matchedFields.length) {
112
+ return "none";
113
+ }
114
+ if (strategy === "fallback_token") {
115
+ return "weak";
116
+ }
117
+ if (signals.exactFields.some((field) => field === "targetNodeTitle" || field === "body" || field === "activityType")) {
118
+ return "strong";
119
+ }
120
+ const termCoverage = signals.totalTermCount > 0 ? signals.matchedTermCount / signals.totalTermCount : 0;
121
+ return strategy === "fts" && termCoverage >= 0.6 ? "strong" : "weak";
122
+ }
123
+ function summarizeLexicalQuality(items) {
124
+ if (items.some((item) => item.lexicalQuality === "strong")) {
125
+ return "strong";
126
+ }
127
+ if (items.some((item) => item.lexicalQuality === "weak")) {
128
+ return "weak";
129
+ }
130
+ return "none";
131
+ }
132
+ function computeWorkspaceResultComposition(input) {
133
+ if (input.nodeCount === 0 && input.activityCount === 0) {
134
+ return "empty";
135
+ }
136
+ if (input.nodeCount > 0 && input.activityCount === 0) {
137
+ return input.semanticUsed ? "semantic_node_only" : "node_only";
138
+ }
139
+ if (input.nodeCount === 0 && input.activityCount > 0) {
140
+ return "activity_only";
141
+ }
142
+ return input.semanticUsed ? "semantic_mixed" : "mixed";
143
+ }
144
+ function mergeLexicalQuality(left, right) {
145
+ if (left === "strong" || right === "strong") {
146
+ return "strong";
147
+ }
148
+ if (left === "weak" || right === "weak") {
149
+ return "weak";
150
+ }
151
+ return "none";
152
+ }
153
+ function mergeNodeSearchItems(primary, secondary) {
154
+ const merged = [...primary];
155
+ const indexById = new Map(primary.map((item, index) => [item.id, index]));
156
+ for (const item of secondary) {
157
+ const existingIndex = indexById.get(item.id);
158
+ if (existingIndex == null) {
159
+ indexById.set(item.id, merged.length);
160
+ merged.push(item);
161
+ continue;
67
162
  }
163
+ const existing = merged[existingIndex];
164
+ merged[existingIndex] = {
165
+ ...existing,
166
+ lexicalQuality: existing.lexicalQuality === "strong" ? "strong" : item.lexicalQuality ?? existing.lexicalQuality,
167
+ matchReason: existing.matchReason && item.matchReason
168
+ ? mergeMatchReasons(existing.matchReason, item.matchReason, existing.matchReason.strategy)
169
+ : existing.matchReason ?? item.matchReason
170
+ };
68
171
  }
69
- return [...matches];
172
+ return merged;
70
173
  }
71
- function buildSearchMatchReason(strategy, matchedFields) {
174
+ function buildSearchMatchReason(strategy, matchedFields, extras = {}) {
72
175
  return {
73
176
  strategy,
74
- matchedFields
177
+ matchedFields,
178
+ ...(extras.strength ? { strength: extras.strength } : {}),
179
+ ...(extras.termCoverage != null ? { termCoverage: extras.termCoverage } : {})
75
180
  };
76
181
  }
77
182
  function mergeMatchReasons(left, right, strategy) {
78
183
  return {
79
184
  strategy,
80
- matchedFields: Array.from(new Set([...(left?.matchedFields ?? []), ...(right?.matchedFields ?? [])]))
185
+ matchedFields: Array.from(new Set([...(left?.matchedFields ?? []), ...(right?.matchedFields ?? [])])),
186
+ strength: left?.strength ?? right?.strength,
187
+ termCoverage: typeof left?.termCoverage === "number" || typeof right?.termCoverage === "number"
188
+ ? Math.max(left?.termCoverage ?? 0, right?.termCoverage ?? 0)
189
+ : null
81
190
  };
82
191
  }
83
192
  function computeWorkspaceRankBonus(index, total) {
@@ -87,8 +196,18 @@ function computeWorkspaceRankBonus(index, total) {
87
196
  return Math.max(0, Math.round(((total - index) / total) * 24));
88
197
  }
89
198
  function computeWorkspaceSmartScore(input) {
199
+ const matchBonus = input.matchReason?.strategy === "semantic"
200
+ ? 3
201
+ : input.lexicalQuality === "strong"
202
+ ? 10
203
+ : input.lexicalQuality === "weak"
204
+ ? input.matchReason?.strategy === "fallback_token"
205
+ ? 1
206
+ : 4
207
+ : 0;
90
208
  return (computeWorkspaceRankBonus(input.index, input.total) +
91
209
  computeWorkspaceRecencyBonusFromAge(input.nowMs - new Date(input.timestamp).getTime(), input.resultType) +
210
+ matchBonus +
92
211
  (input.resultType === "activity" ? 4 : 0) -
93
212
  (input.contested ? 20 : 0));
94
213
  }
@@ -132,6 +251,9 @@ function readNumberSetting(settings, key, fallback) {
132
251
  const value = settings[key];
133
252
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
134
253
  }
254
+ function normalizeWorkspaceSemanticFallbackMode(value) {
255
+ return value === "no_strong_node_hit" ? "no_strong_node_hit" : DEFAULT_WORKSPACE_SEMANTIC_FALLBACK_MODE;
256
+ }
135
257
  function normalizeSemanticIndexBackend(value) {
136
258
  return value === "sqlite-vec" ? "sqlite-vec" : "sqlite";
137
259
  }
@@ -171,7 +293,9 @@ function readSemanticIndexSettingSnapshot(settings, runtime) {
171
293
  indexBackend: resolveActiveSemanticIndexBackend(configuredIndexBackend, runtime.sqliteVecLoaded),
172
294
  extensionStatus: resolveSemanticExtensionStatus(configuredIndexBackend, runtime.sqliteVecLoaded),
173
295
  extensionLoadError: configuredIndexBackend === "sqlite-vec" && !runtime.sqliteVecLoaded ? runtime.sqliteVecLoadError : null,
174
- chunkEnabled: readBooleanSetting(settings, "search.semantic.chunk.enabled", false)
296
+ chunkEnabled: readBooleanSetting(settings, "search.semantic.chunk.enabled", false),
297
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
298
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"])
175
299
  };
176
300
  }
177
301
  function shouldReindexForSemanticConfigChange(previous, next) {
@@ -601,7 +725,8 @@ export class RecallXRepository {
601
725
  "search.semantic.indexBackend",
602
726
  "search.semantic.chunk.enabled",
603
727
  "search.semantic.chunk.aggregation",
604
- "search.semantic.workspaceFallback.enabled"
728
+ "search.semantic.workspaceFallback.enabled",
729
+ "search.semantic.workspaceFallback.mode"
605
730
  ]);
606
731
  return {
607
732
  ...readSemanticIndexSettingSnapshot(settings, {
@@ -609,7 +734,8 @@ export class RecallXRepository {
609
734
  sqliteVecLoadError: this.sqliteVecRuntime.loadError
610
735
  }),
611
736
  chunkAggregation: normalizeSemanticChunkAggregation(settings["search.semantic.chunk.aggregation"]),
612
- workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false)
737
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
738
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"])
613
739
  };
614
740
  }
615
741
  getSemanticAugmentationSettings() {
@@ -1010,6 +1136,8 @@ export class RecallXRepository {
1010
1136
  "search.semantic.model",
1011
1137
  "search.semantic.indexBackend",
1012
1138
  "search.semantic.chunk.enabled",
1139
+ "search.semantic.workspaceFallback.enabled",
1140
+ "search.semantic.workspaceFallback.mode",
1013
1141
  "search.semantic.last_backfill_at"
1014
1142
  ]);
1015
1143
  const semanticSettings = readSemanticIndexSettingSnapshot(settings, {
@@ -1031,6 +1159,8 @@ export class RecallXRepository {
1031
1159
  }
1032
1160
  return {
1033
1161
  ...semanticStatusSettings,
1162
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
1163
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"]),
1034
1164
  lastBackfillAt: readStringSetting(settings, "search.semantic.last_backfill_at"),
1035
1165
  counts
1036
1166
  };
@@ -1303,6 +1433,7 @@ export class RecallXRepository {
1303
1433
  const result = this.searchNodesWithFts(input);
1304
1434
  appendCurrentTelemetryDetails({
1305
1435
  ftsFallback: false,
1436
+ lexicalQuality: summarizeLexicalQuality(result.items),
1306
1437
  resultCount: result.items.length,
1307
1438
  totalCount: result.total
1308
1439
  });
@@ -1312,6 +1443,7 @@ export class RecallXRepository {
1312
1443
  const fallbackResult = this.searchNodesWithLike(input);
1313
1444
  appendCurrentTelemetryDetails({
1314
1445
  ftsFallback: true,
1446
+ lexicalQuality: summarizeLexicalQuality(fallbackResult.items),
1315
1447
  resultCount: fallbackResult.items.length,
1316
1448
  totalCount: fallbackResult.total
1317
1449
  });
@@ -1321,6 +1453,7 @@ export class RecallXRepository {
1321
1453
  const result = this.searchNodesWithLike(input);
1322
1454
  appendCurrentTelemetryDetails({
1323
1455
  ftsFallback: false,
1456
+ lexicalQuality: summarizeLexicalQuality(result.items),
1324
1457
  resultCount: result.items.length,
1325
1458
  totalCount: result.total
1326
1459
  });
@@ -1332,6 +1465,7 @@ export class RecallXRepository {
1332
1465
  const result = this.searchActivitiesWithFts(input);
1333
1466
  appendCurrentTelemetryDetails({
1334
1467
  ftsFallback: false,
1468
+ lexicalQuality: summarizeLexicalQuality(result.items),
1335
1469
  resultCount: result.items.length,
1336
1470
  totalCount: result.total
1337
1471
  });
@@ -1341,6 +1475,7 @@ export class RecallXRepository {
1341
1475
  const fallbackResult = this.searchActivitiesWithLike(input);
1342
1476
  appendCurrentTelemetryDetails({
1343
1477
  ftsFallback: true,
1478
+ lexicalQuality: summarizeLexicalQuality(fallbackResult.items),
1344
1479
  resultCount: fallbackResult.items.length,
1345
1480
  totalCount: fallbackResult.total
1346
1481
  });
@@ -1350,6 +1485,7 @@ export class RecallXRepository {
1350
1485
  const result = this.searchActivitiesWithLike(input);
1351
1486
  appendCurrentTelemetryDetails({
1352
1487
  ftsFallback: false,
1488
+ lexicalQuality: summarizeLexicalQuality(result.items),
1353
1489
  resultCount: result.items.length,
1354
1490
  totalCount: result.total
1355
1491
  });
@@ -1465,6 +1601,8 @@ export class RecallXRepository {
1465
1601
  const resolvedActivityResults = fallbackTokens.length >= 2 && includeActivities
1466
1602
  ? this.searchWorkspaceActivityFallback(fallbackTokens, input.activityFilters ?? {}, requestedWindow)
1467
1603
  : activityResults;
1604
+ const bestNodeLexicalQuality = summarizeLexicalQuality(resolvedNodeResults.items);
1605
+ const bestActivityLexicalQuality = summarizeLexicalQuality(resolvedActivityResults.items);
1468
1606
  const merged = this.mergeWorkspaceSearchResults(resolvedNodeResults.items, resolvedActivityResults.items, input.sort);
1469
1607
  const deterministicResult = {
1470
1608
  total: fallbackTokens.length >= 2
@@ -1477,6 +1615,7 @@ export class RecallXRepository {
1477
1615
  semanticFallbackEligible: false,
1478
1616
  semanticFallbackAttempted: false,
1479
1617
  semanticFallbackUsed: false,
1618
+ semanticFallbackMode: includeNodes && semanticSettings.workspaceFallbackEnabled ? semanticSettings.workspaceFallbackMode : null,
1480
1619
  semanticFallbackCandidateCount: 0,
1481
1620
  semanticFallbackResultCount: 0,
1482
1621
  semanticFallbackBackend: null,
@@ -1485,16 +1624,31 @@ export class RecallXRepository {
1485
1624
  semanticFallbackQueryLengthBucket: queryPresent ? bucketSemanticQueryLength(normalizedQuery.length) : null
1486
1625
  };
1487
1626
  const appendWorkspaceSearchTelemetry = (result) => {
1627
+ const nodeItems = result.items.flatMap((item) => item.resultType === "node" && item.node ? [item.node] : []);
1628
+ const activityItems = result.items.flatMap((item) => item.resultType === "activity" && item.activity ? [item.activity] : []);
1488
1629
  appendCurrentTelemetryDetails({
1630
+ searchHit: result.items.length > 0,
1489
1631
  candidateCount: requestedWindow,
1490
1632
  nodeCandidateCount: resolvedNodeResults.items.length,
1491
1633
  activityCandidateCount: resolvedActivityResults.items.length,
1634
+ nodeResultCount: nodeItems.length,
1635
+ activityResultCount: activityItems.length,
1636
+ bestNodeLexicalQuality,
1637
+ bestActivityLexicalQuality,
1638
+ lexicalNodeHit: bestNodeLexicalQuality !== "none",
1639
+ strongNodeLexicalHit: bestNodeLexicalQuality === "strong",
1640
+ resultComposition: computeWorkspaceResultComposition({
1641
+ nodeCount: nodeItems.length,
1642
+ activityCount: activityItems.length,
1643
+ semanticUsed: telemetry.semanticFallbackUsed
1644
+ }),
1492
1645
  resultCount: result.items.length,
1493
1646
  totalCount: result.total,
1494
1647
  fallbackTokenCount: fallbackTokens.length,
1495
1648
  semanticFallbackEligible: telemetry.semanticFallbackEligible,
1496
1649
  semanticFallbackAttempted: telemetry.semanticFallbackAttempted,
1497
1650
  semanticFallbackUsed: telemetry.semanticFallbackUsed,
1651
+ semanticFallbackMode: telemetry.semanticFallbackMode ?? undefined,
1498
1652
  semanticFallbackCandidateCount: telemetry.semanticFallbackCandidateCount,
1499
1653
  semanticFallbackResultCount: telemetry.semanticFallbackResultCount,
1500
1654
  semanticFallbackBackend: telemetry.semanticFallbackBackend,
@@ -1502,13 +1656,18 @@ export class RecallXRepository {
1502
1656
  semanticFallbackSkippedReason: telemetry.semanticFallbackSkippedReason
1503
1657
  });
1504
1658
  };
1659
+ const strictZeroFallbackBlocked = resolvedNodeResults.total + resolvedActivityResults.total > 0;
1660
+ const noStrongNodeFallbackBlocked = bestNodeLexicalQuality === "strong";
1661
+ const semanticFallbackBlockedByMode = semanticSettings.workspaceFallbackMode === "strict_zero"
1662
+ ? strictZeroFallbackBlocked
1663
+ : noStrongNodeFallbackBlocked;
1505
1664
  const shouldAttemptSemanticFallback = includeNodes &&
1506
1665
  semanticSettings.workspaceFallbackEnabled &&
1507
1666
  queryPresent &&
1508
1667
  normalizedQuery.length >= 6 &&
1509
- deterministicResult.total === 0 &&
1510
1668
  semanticSettings.enabled &&
1511
- Boolean(semanticSettings.provider && semanticSettings.model);
1669
+ Boolean(semanticSettings.provider && semanticSettings.model) &&
1670
+ !semanticFallbackBlockedByMode;
1512
1671
  if (!includeNodes) {
1513
1672
  telemetry.semanticFallbackSkippedReason = "nodes_scope_disabled";
1514
1673
  }
@@ -1521,15 +1680,18 @@ export class RecallXRepository {
1521
1680
  else if (!semanticSettings.workspaceFallbackEnabled) {
1522
1681
  telemetry.semanticFallbackSkippedReason = "workspace_fallback_disabled";
1523
1682
  }
1524
- else if (deterministicResult.total > 0) {
1525
- telemetry.semanticFallbackSkippedReason = "deterministic_results_present";
1526
- }
1527
1683
  else if (!semanticSettings.enabled) {
1528
1684
  telemetry.semanticFallbackSkippedReason = "semantic_disabled";
1529
1685
  }
1530
1686
  else if (!semanticSettings.provider || !semanticSettings.model) {
1531
1687
  telemetry.semanticFallbackSkippedReason = "semantic_provider_unconfigured";
1532
1688
  }
1689
+ else if (semanticSettings.workspaceFallbackMode === "strict_zero" && strictZeroFallbackBlocked) {
1690
+ telemetry.semanticFallbackSkippedReason = "strict_zero_results_present";
1691
+ }
1692
+ else if (semanticSettings.workspaceFallbackMode === "no_strong_node_hit" && noStrongNodeFallbackBlocked) {
1693
+ telemetry.semanticFallbackSkippedReason = "strong_node_lexical_present";
1694
+ }
1533
1695
  if (shouldAttemptSemanticFallback) {
1534
1696
  const candidateNodeIds = this.listWorkspaceSemanticFallbackCandidateNodeIds(input.nodeFilters ?? {}, semanticSettings, 200);
1535
1697
  telemetry.semanticFallbackEligible = true;
@@ -1541,8 +1703,7 @@ export class RecallXRepository {
1541
1703
  else {
1542
1704
  telemetry.semanticFallbackAttempted = true;
1543
1705
  const runSemanticFallback = async () => {
1544
- const semanticMatches = await this.rankSemanticCandidates(normalizedQuery, candidateNodeIds);
1545
- const items = this.buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, semanticMatches, this.getSemanticAugmentationSettings()).map((node) => ({ resultType: "node", node }));
1706
+ const items = this.buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, await this.rankSemanticCandidates(normalizedQuery, candidateNodeIds), this.getSemanticAugmentationSettings());
1546
1707
  return {
1547
1708
  items,
1548
1709
  resultCount: items.length
@@ -1554,15 +1715,18 @@ export class RecallXRepository {
1554
1715
  semanticFallbackCandidateCount: candidateNodeIds.length,
1555
1716
  semanticFallbackBackend: semanticSettings.indexBackend,
1556
1717
  semanticFallbackConfiguredBackend: semanticSettings.configuredIndexBackend,
1718
+ semanticFallbackMode: telemetry.semanticFallbackMode ?? undefined,
1557
1719
  semanticFallbackQueryLengthBucket: telemetry.semanticFallbackQueryLengthBucket
1558
1720
  }, runSemanticFallback)
1559
1721
  : await runSemanticFallback();
1560
1722
  telemetry.semanticFallbackResultCount = semanticResult.resultCount;
1561
1723
  if (semanticResult.resultCount > 0) {
1562
1724
  telemetry.semanticFallbackUsed = true;
1725
+ const mergedNodeItems = mergeNodeSearchItems(semanticResult.items, resolvedNodeResults.items);
1726
+ const mergedSemanticItems = this.mergeWorkspaceSearchResults(mergedNodeItems, resolvedActivityResults.items, input.sort);
1563
1727
  const semanticWorkspaceResult = {
1564
- total: semanticResult.resultCount,
1565
- items: semanticResult.items
1728
+ total: mergedNodeItems.length + (includeActivities ? resolvedActivityResults.total : 0),
1729
+ items: mergedSemanticItems.slice(input.offset, input.offset + input.limit)
1566
1730
  };
1567
1731
  appendWorkspaceSearchTelemetry(semanticWorkspaceResult);
1568
1732
  return {
@@ -1638,6 +1802,8 @@ export class RecallXRepository {
1638
1802
  timestamp: node.updatedAt,
1639
1803
  resultType: "node",
1640
1804
  contested: node.status === "contested",
1805
+ matchReason: node.matchReason,
1806
+ lexicalQuality: node.lexicalQuality,
1641
1807
  nowMs
1642
1808
  })
1643
1809
  : 0
@@ -1656,6 +1822,8 @@ export class RecallXRepository {
1656
1822
  timestamp: activity.createdAt,
1657
1823
  resultType: "activity",
1658
1824
  contested: activity.targetNodeStatus === "contested",
1825
+ matchReason: activity.matchReason,
1826
+ lexicalQuality: activity.lexicalQuality,
1659
1827
  nowMs
1660
1828
  })
1661
1829
  : 0
@@ -1673,6 +1841,24 @@ export class RecallXRepository {
1673
1841
  }
1674
1842
  return merged.map(({ index: _index, total: _total, timestamp: _timestamp, contested: _contested, smartScore: _smartScore, ...item }) => item);
1675
1843
  }
1844
+ mergeNodeSearchResults(primary, secondary) {
1845
+ const merged = new Map();
1846
+ for (const item of [...primary, ...secondary]) {
1847
+ const current = merged.get(item.id);
1848
+ if (!current) {
1849
+ merged.set(item.id, item);
1850
+ continue;
1851
+ }
1852
+ merged.set(item.id, {
1853
+ ...current,
1854
+ matchReason: current.matchReason?.strategy === "semantic" && item.matchReason
1855
+ ? item.matchReason
1856
+ : current.matchReason ?? item.matchReason,
1857
+ lexicalQuality: mergeLexicalQuality(current.lexicalQuality, item.lexicalQuality)
1858
+ });
1859
+ }
1860
+ return Array.from(merged.values());
1861
+ }
1676
1862
  searchNodesWithFts(input) {
1677
1863
  const where = [];
1678
1864
  const values = [];
@@ -1851,23 +2037,31 @@ export class RecallXRepository {
1851
2037
  LIMIT ? OFFSET ?`)
1852
2038
  .all(...whereValues, ...params.orderValues, effectiveLimit, effectiveOffset);
1853
2039
  const matcher = params.strategy === "browse" ? null : createSearchFieldMatcher(params.input.query);
1854
- const items = rows.map((row) => ({
1855
- id: String(row.id),
1856
- targetNodeId: String(row.target_node_id),
1857
- targetNodeTitle: row.target_title ? String(row.target_title) : null,
1858
- targetNodeType: row.target_type ? row.target_type : null,
1859
- targetNodeStatus: row.target_status ? row.target_status : null,
1860
- activityType: row.activity_type,
1861
- body: row.body ? String(row.body) : null,
1862
- sourceLabel: row.source_label ? String(row.source_label) : null,
1863
- createdAt: String(row.created_at),
1864
- matchReason: buildSearchMatchReason(params.strategy, collectMatchedFields(matcher, [
2040
+ const items = rows.map((row) => {
2041
+ const signals = collectSearchFieldSignals(matcher, [
1865
2042
  { field: "body", value: row.body ? String(row.body) : null },
1866
2043
  { field: "targetNodeTitle", value: row.target_title ? String(row.target_title) : null },
1867
2044
  { field: "activityType", value: row.activity_type ? String(row.activity_type) : null },
1868
2045
  { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1869
- ]))
1870
- }));
2046
+ ]);
2047
+ const lexicalQuality = classifyActivityLexicalQuality(params.strategy, signals);
2048
+ return {
2049
+ id: String(row.id),
2050
+ targetNodeId: String(row.target_node_id),
2051
+ targetNodeTitle: row.target_title ? String(row.target_title) : null,
2052
+ targetNodeType: row.target_type ? row.target_type : null,
2053
+ targetNodeStatus: row.target_status ? row.target_status : null,
2054
+ activityType: row.activity_type,
2055
+ body: row.body ? String(row.body) : null,
2056
+ sourceLabel: row.source_label ? String(row.source_label) : null,
2057
+ createdAt: String(row.created_at),
2058
+ lexicalQuality,
2059
+ matchReason: buildSearchMatchReason(params.strategy, signals.matchedFields, {
2060
+ strength: lexicalQuality === "none" ? undefined : lexicalQuality,
2061
+ termCoverage: signals.totalTermCount > 0 ? Number((signals.matchedTermCount / signals.totalTermCount).toFixed(4)) : null
2062
+ })
2063
+ };
2064
+ });
1871
2065
  const rankedItems = useSearchFeedbackBoost ? this.applyActivitySearchFeedbackBoost(items) : items;
1872
2066
  const cappedItems = this.capActivityResultsPerTarget(rankedItems);
1873
2067
  return {
@@ -1914,6 +2108,14 @@ export class RecallXRepository {
1914
2108
  const matcher = strategy === "browse" ? null : createSearchFieldMatcher(query);
1915
2109
  const items = rows.map((row) => {
1916
2110
  const tags = parseJson(row.tags_json, []);
2111
+ const signals = collectSearchFieldSignals(matcher, [
2112
+ { field: "title", value: row.title ? String(row.title) : null },
2113
+ { field: "summary", value: row.summary ? String(row.summary) : null },
2114
+ { field: "body", value: row.body ? String(row.body) : null },
2115
+ { field: "tags", value: tags.join(" ") },
2116
+ { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
2117
+ ]);
2118
+ const lexicalQuality = classifyNodeLexicalQuality(strategy, signals);
1917
2119
  return {
1918
2120
  id: String(row.id),
1919
2121
  type: row.type,
@@ -1924,13 +2126,11 @@ export class RecallXRepository {
1924
2126
  sourceLabel: row.source_label ? String(row.source_label) : null,
1925
2127
  updatedAt: String(row.updated_at),
1926
2128
  tags,
1927
- matchReason: buildSearchMatchReason(strategy, collectMatchedFields(matcher, [
1928
- { field: "title", value: row.title ? String(row.title) : null },
1929
- { field: "summary", value: row.summary ? String(row.summary) : null },
1930
- { field: "body", value: row.body ? String(row.body) : null },
1931
- { field: "tags", value: tags.join(" ") },
1932
- { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1933
- ]))
2129
+ lexicalQuality,
2130
+ matchReason: buildSearchMatchReason(strategy, signals.matchedFields, {
2131
+ strength: lexicalQuality === "none" ? undefined : lexicalQuality,
2132
+ termCoverage: signals.totalTermCount > 0 ? Number((signals.matchedTermCount / signals.totalTermCount).toFixed(4)) : null
2133
+ })
1934
2134
  };
1935
2135
  });
1936
2136
  const rankedItems = useSearchFeedbackBoost ? this.applySearchFeedbackBoost(items) : items;
@@ -76,6 +76,7 @@ export class WorkspaceSessionManager {
76
76
  "search.semantic.chunk.enabled": false,
77
77
  "search.semantic.chunk.aggregation": "max",
78
78
  "search.semantic.workspaceFallback.enabled": false,
79
+ "search.semantic.workspaceFallback.mode": "strict_zero",
79
80
  "search.semantic.augmentation.minSimilarity": 0.2,
80
81
  "search.semantic.augmentation.maxBonus": 18,
81
82
  "search.semantic.last_backfill_at": null,
@@ -1 +1 @@
1
- export const RECALLX_VERSION = "1.0.0";
1
+ export const RECALLX_VERSION = "1.0.2";