@zokizuan/satori-mcp 4.5.0 → 4.6.0

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.
@@ -79,6 +79,15 @@ export declare class ToolHandlers {
79
79
  private tokenizeQueryPrefix;
80
80
  private unquoteOperatorValue;
81
81
  private parseSearchOperators;
82
+ private tokenizeLexicalTerms;
83
+ private isIdentifierLikeToken;
84
+ private buildSearchQueryPlan;
85
+ private escapeLexicalRegex;
86
+ private hasTokenBoundaryMatch;
87
+ private getReferenceUsageKind;
88
+ private hasDeclarationMatch;
89
+ private getLexicalTermFactor;
90
+ private scoreCandidateLexicalEvidence;
82
91
  private pathMatchesAnyPattern;
83
92
  private tokenMatchesAnyField;
84
93
  private resolveRerankDecision;
@@ -94,6 +103,11 @@ export declare class ToolHandlers {
94
103
  private getStalenessBucket;
95
104
  private compareNullableNumbersAsc;
96
105
  private compareNullableStringsAsc;
106
+ private compareSearchCandidates;
107
+ private sortSearchCandidates;
108
+ private isDeclarationSearchGroup;
109
+ private normalizeDeclarationGroupKey;
110
+ private collapseDuplicateDeclarationGroups;
97
111
  private buildFallbackGroupId;
98
112
  private isCallGraphLanguageSupported;
99
113
  private buildCallGraphHint;
@@ -21,6 +21,11 @@ const ZILLIZ_FREE_TIER_COLLECTION_LIMIT = 5;
21
21
  const OUTLINE_SUPPORTED_EXTENSIONS = getSupportedExtensionsForCapability('fileOutline');
22
22
  const MIN_RELIABLE_COLLECTION_CREATED_AT_MS = Date.UTC(2000, 0, 1);
23
23
  const SEARCH_OPERATOR_KEYS = new Set(['lang', 'path', '-path', 'must', 'exclude']);
24
+ const SEARCH_QUERY_STOPWORDS = new Set([
25
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'find', 'for', 'from', 'how',
26
+ 'in', 'is', 'it', 'logic', 'of', 'or', 'the', 'to', 'used', 'uses', 'using',
27
+ 'what', 'where', 'which', 'who', 'why'
28
+ ]);
24
29
  const NAVIGATION_FALLBACK_MESSAGE = 'Call graph not available for this result; use readSpan or fileOutlineWindow to navigate.';
25
30
  // Recovery probe threshold for "likely interrupted" indexing states.
26
31
  // Keep this shorter than snapshot merge stale semantics for better operator UX.
@@ -1235,6 +1240,241 @@ export class ToolHandlers {
1235
1240
  operators.semanticQuery = semanticParts.length > 0 ? semanticParts.join("\n") : trimmedQuery;
1236
1241
  return operators;
1237
1242
  }
1243
+ tokenizeLexicalTerms(tokens) {
1244
+ const terms = new Map();
1245
+ const addTerm = (value, kind) => {
1246
+ const normalized = value
1247
+ .replace(/^['"`]+|['"`]+$/g, '')
1248
+ .replace(/[(){}\[\],;]+/g, ' ')
1249
+ .trim()
1250
+ .toLowerCase();
1251
+ if (normalized.length === 0) {
1252
+ return;
1253
+ }
1254
+ const existing = terms.get(normalized);
1255
+ if (!existing || (existing.kind === 'fragment' && kind === 'whole')) {
1256
+ terms.set(normalized, { value: normalized, kind });
1257
+ }
1258
+ };
1259
+ for (const token of tokens) {
1260
+ const trimmed = token.trim();
1261
+ if (trimmed.length === 0) {
1262
+ continue;
1263
+ }
1264
+ addTerm(trimmed, 'whole');
1265
+ const expanded = trimmed
1266
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1267
+ .replace(/[/\\._:-]+/g, ' ')
1268
+ .replace(/[(){}\[\],;]+/g, ' ')
1269
+ .toLowerCase();
1270
+ for (const part of expanded.split(/\s+/)) {
1271
+ const normalizedPart = part.trim();
1272
+ if (normalizedPart.length >= 2) {
1273
+ addTerm(normalizedPart, 'fragment');
1274
+ }
1275
+ }
1276
+ }
1277
+ return Array.from(terms.values());
1278
+ }
1279
+ isIdentifierLikeToken(token) {
1280
+ const trimmed = token.trim();
1281
+ if (trimmed.length === 0) {
1282
+ return false;
1283
+ }
1284
+ return /[A-Z]/.test(trimmed)
1285
+ || /[_/\\.\-:]/.test(trimmed)
1286
+ || /\d/.test(trimmed);
1287
+ }
1288
+ buildSearchQueryPlan(semanticQuery) {
1289
+ const hybridEnabled = this.runtimeFingerprint.schemaVersion.startsWith('hybrid');
1290
+ const tokens = semanticQuery
1291
+ .split(/\s+/)
1292
+ .map((token) => token.trim())
1293
+ .filter((token) => token.length > 0);
1294
+ const normalizedQuery = semanticQuery.toLowerCase();
1295
+ const normalizedTokens = tokens.map((token) => token.toLowerCase());
1296
+ const identifierTokens = tokens.filter((token) => this.isIdentifierLikeToken(token));
1297
+ const naturalLanguageTokens = tokens
1298
+ .filter((token) => (!this.isIdentifierLikeToken(token)
1299
+ && (SEARCH_QUERY_STOPWORDS.has(token.toLowerCase()) || token.length >= 4)))
1300
+ .map((token) => token.toLowerCase());
1301
+ const singleBareLookup = tokens.length === 1
1302
+ && /^[a-z][a-z0-9]{2,63}$/.test(tokens[0])
1303
+ && !SEARCH_QUERY_STOPWORDS.has(normalizedTokens[0] || '');
1304
+ const lexicalTerms = this
1305
+ .tokenizeLexicalTerms(identifierTokens.length > 0 ? identifierTokens : tokens)
1306
+ .filter((term) => !SEARCH_QUERY_STOPWORDS.has(term.value))
1307
+ .slice(0, 8);
1308
+ const referenceSeeking = /\b(used|uses|usage|reference|references|referenced|callers?|called|imports?|imported|instantiat(?:e|ed|ion))\b/.test(normalizedQuery)
1309
+ || /\bwhere\s+is\b/.test(normalizedQuery)
1310
+ || /\bwho\s+uses\b/.test(normalizedQuery);
1311
+ let intent = 'uncertain';
1312
+ let confidence = 'low';
1313
+ const reasons = [];
1314
+ if (identifierTokens.length > 0 && naturalLanguageTokens.length > 0) {
1315
+ intent = 'mixed';
1316
+ confidence = identifierTokens.length >= 2 ? 'high' : 'medium';
1317
+ reasons.push('identifier_terms_present', 'natural_language_terms_present');
1318
+ }
1319
+ else if (identifierTokens.length > 0) {
1320
+ intent = 'identifier';
1321
+ confidence = tokens.length === identifierTokens.length ? 'high' : 'medium';
1322
+ reasons.push(tokens.length === 1 ? 'single_identifier_token' : 'identifier_tokens_present');
1323
+ }
1324
+ else if (singleBareLookup) {
1325
+ intent = 'uncertain';
1326
+ confidence = 'medium';
1327
+ reasons.push('single_term_lookup');
1328
+ }
1329
+ else if (naturalLanguageTokens.length >= 2 || tokens.length >= 4) {
1330
+ intent = 'semantic';
1331
+ confidence = 'high';
1332
+ reasons.push('natural_language_query');
1333
+ }
1334
+ else {
1335
+ reasons.push('ambiguous_short_query');
1336
+ }
1337
+ if (referenceSeeking) {
1338
+ reasons.push('reference_seeking_query');
1339
+ }
1340
+ return {
1341
+ semanticQuery,
1342
+ intent,
1343
+ confidence,
1344
+ reasons,
1345
+ referenceSeeking,
1346
+ lexicalTerms,
1347
+ retrievalMode: hybridEnabled
1348
+ ? (intent === 'identifier' ? 'lexical' : 'hybrid')
1349
+ : 'dense',
1350
+ scorePolicyKind: 'topk_only',
1351
+ lexicalWeight: intent === 'identifier'
1352
+ ? 1.35
1353
+ : intent === 'mixed'
1354
+ ? (referenceSeeking ? 0.18 : 0.05)
1355
+ : intent === 'uncertain'
1356
+ ? 0.60
1357
+ : 0.00,
1358
+ exactMatchPinningEnabled: intent !== 'semantic' && !referenceSeeking,
1359
+ rerankAllowed: intent !== 'identifier',
1360
+ };
1361
+ }
1362
+ escapeLexicalRegex(value) {
1363
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1364
+ }
1365
+ hasTokenBoundaryMatch(field, term) {
1366
+ if (!field || !term) {
1367
+ return false;
1368
+ }
1369
+ const pattern = new RegExp(`(^|[^a-z0-9])${this.escapeLexicalRegex(term)}([^a-z0-9]|$)`, 'i');
1370
+ return pattern.test(field);
1371
+ }
1372
+ getReferenceUsageKind(content, term) {
1373
+ if (!content || !term) {
1374
+ return null;
1375
+ }
1376
+ const escaped = this.escapeLexicalRegex(term);
1377
+ const executablePatterns = [
1378
+ new RegExp(`\\bnew\\s+${escaped}\\b`, 'i'),
1379
+ new RegExp(`\\b${escaped}\\s*\\(`, 'i'),
1380
+ new RegExp(`\\b${escaped}\\b\\s*=`, 'i'),
1381
+ ];
1382
+ if (executablePatterns.some((pattern) => pattern.test(content))) {
1383
+ return 'executable';
1384
+ }
1385
+ const importPatterns = [
1386
+ new RegExp(`\\bimport\\s+.*\\b${escaped}\\b`, 'i'),
1387
+ new RegExp(`\\bfrom\\s+.+\\s+import\\s+.*\\b${escaped}\\b`, 'i'),
1388
+ ];
1389
+ return importPatterns.some((pattern) => pattern.test(content)) ? 'import' : null;
1390
+ }
1391
+ hasDeclarationMatch(content, term) {
1392
+ if (!content || !term) {
1393
+ return false;
1394
+ }
1395
+ const escaped = this.escapeLexicalRegex(term);
1396
+ const declarationPatterns = [
1397
+ new RegExp(`\\bclass\\s+${escaped}\\b`, 'i'),
1398
+ new RegExp(`\\bdef\\s+${escaped}\\b`, 'i'),
1399
+ new RegExp(`\\bfunction\\s+${escaped}\\b`, 'i'),
1400
+ new RegExp(`\\btype\\s+${escaped}\\b`, 'i'),
1401
+ new RegExp(`\\binterface\\s+${escaped}\\b`, 'i'),
1402
+ new RegExp(`\\benum\\s+${escaped}\\b`, 'i'),
1403
+ new RegExp(`\\bstruct\\s+${escaped}\\b`, 'i'),
1404
+ new RegExp(`\\b(?:const|let|var)\\s+${escaped}\\b\\s*=\\s*(?:async\\s+)?function\\b`, 'i'),
1405
+ new RegExp(`\\b(?:const|let|var)\\s+${escaped}\\b\\s*=\\s*(?:async\\s*)?(?:\\([^)]*\\)|[a-z_$][\\w$]*)\\s*=>`, 'i'),
1406
+ ];
1407
+ return declarationPatterns.some((pattern) => pattern.test(content));
1408
+ }
1409
+ getLexicalTermFactor(plan, term) {
1410
+ if (term.kind === 'whole') {
1411
+ return 1;
1412
+ }
1413
+ if (plan.referenceSeeking) {
1414
+ return 0.18;
1415
+ }
1416
+ if (plan.intent === 'identifier') {
1417
+ return 0.18;
1418
+ }
1419
+ return 0.35;
1420
+ }
1421
+ scoreCandidateLexicalEvidence(plan, result) {
1422
+ if (plan.lexicalTerms.length === 0) {
1423
+ return { score: 0, exactLexicalMatch: false };
1424
+ }
1425
+ const relativePath = typeof result?.relativePath === 'string' ? result.relativePath.toLowerCase() : '';
1426
+ const symbolLabel = typeof result?.symbolLabel === 'string' ? result.symbolLabel.toLowerCase() : '';
1427
+ const content = typeof result?.content === 'string' ? result.content.toLowerCase() : '';
1428
+ const pathSegments = relativePath.split('/').filter((segment) => segment.length > 0);
1429
+ let score = 0;
1430
+ let exactLexicalMatch = false;
1431
+ for (const term of plan.lexicalTerms) {
1432
+ const usageKind = plan.referenceSeeking ? this.getReferenceUsageKind(content, term.value) : null;
1433
+ const declarationMatch = plan.referenceSeeking && this.hasDeclarationMatch(content, term.value);
1434
+ const termFactor = this.getLexicalTermFactor(plan, term);
1435
+ if (usageKind === 'executable' && !declarationMatch) {
1436
+ score = Math.max(score, 1.60 * termFactor);
1437
+ continue;
1438
+ }
1439
+ if (usageKind === 'import' && !declarationMatch) {
1440
+ score = Math.max(score, 0.75 * termFactor);
1441
+ continue;
1442
+ }
1443
+ if (this.hasTokenBoundaryMatch(symbolLabel, term.value)) {
1444
+ score = Math.max(score, (plan.referenceSeeking ? 0.02 : 1.30) * termFactor);
1445
+ if (!plan.referenceSeeking && term.kind === 'whole') {
1446
+ exactLexicalMatch = true;
1447
+ }
1448
+ continue;
1449
+ }
1450
+ if (pathSegments.some((segment) => this.hasTokenBoundaryMatch(segment, term.value))) {
1451
+ score = Math.max(score, (plan.referenceSeeking ? 0.02 : 1.20) * termFactor);
1452
+ if (!plan.referenceSeeking && term.kind === 'whole') {
1453
+ exactLexicalMatch = true;
1454
+ }
1455
+ continue;
1456
+ }
1457
+ if (this.hasTokenBoundaryMatch(content, term.value)) {
1458
+ score = Math.max(score, (plan.referenceSeeking ? (declarationMatch ? 0.10 : 1.25) : 0.90) * termFactor);
1459
+ continue;
1460
+ }
1461
+ if (symbolLabel.includes(term.value)) {
1462
+ score = Math.max(score, (plan.referenceSeeking ? 0.04 : 0.55) * termFactor);
1463
+ continue;
1464
+ }
1465
+ if (relativePath.includes(term.value)) {
1466
+ score = Math.max(score, (plan.referenceSeeking ? 0.04 : 0.45) * termFactor);
1467
+ continue;
1468
+ }
1469
+ if (content.includes(term.value)) {
1470
+ score = Math.max(score, (plan.referenceSeeking ? (declarationMatch ? 0.08 : 0.30) : 0.25) * termFactor);
1471
+ }
1472
+ }
1473
+ return {
1474
+ score: score * plan.lexicalWeight,
1475
+ exactLexicalMatch,
1476
+ };
1477
+ }
1238
1478
  pathMatchesAnyPattern(relativePath, patterns) {
1239
1479
  if (patterns.length === 0)
1240
1480
  return false;
@@ -1261,17 +1501,20 @@ export class ToolHandlers {
1261
1501
  }
1262
1502
  return false;
1263
1503
  }
1264
- resolveRerankDecision(scope) {
1504
+ resolveRerankDecision(scope, plan) {
1265
1505
  const capabilityPresent = this.capabilities.hasReranker();
1266
1506
  const enabledByPolicy = capabilityPresent && this.capabilities.getDefaultRerankEnabled();
1267
1507
  const rerankerPresent = this.reranker !== null;
1268
1508
  const skippedByScopeDocs = scope === 'docs';
1509
+ const skippedByIdentifierIntent = !plan.rerankAllowed;
1269
1510
  return {
1270
1511
  enabledByPolicy,
1271
1512
  skippedByScopeDocs,
1513
+ skippedByIdentifierIntent,
1272
1514
  capabilityPresent,
1273
1515
  rerankerPresent,
1274
- enabled: enabledByPolicy && rerankerPresent && !skippedByScopeDocs,
1516
+ enabled: enabledByPolicy && rerankerPresent && !skippedByScopeDocs && !skippedByIdentifierIntent,
1517
+ exactMatchPinningEnabled: plan.exactMatchPinningEnabled,
1275
1518
  };
1276
1519
  }
1277
1520
  buildRerankDocument(result) {
@@ -1519,6 +1762,122 @@ export class ToolHandlers {
1519
1762
  return -1;
1520
1763
  return a.localeCompare(b);
1521
1764
  }
1765
+ compareSearchCandidates(a, b, options) {
1766
+ if (options?.mustMatchesFirst === true && a.passesMatchedMust !== b.passesMatchedMust) {
1767
+ return a.passesMatchedMust ? -1 : 1;
1768
+ }
1769
+ if (options?.exactMatchFirst === true && a.exactLexicalMatch !== b.exactLexicalMatch) {
1770
+ return a.exactLexicalMatch ? -1 : 1;
1771
+ }
1772
+ if (b.finalScore !== a.finalScore)
1773
+ return b.finalScore - a.finalScore;
1774
+ const fileCmp = this.compareNullableStringsAsc(a.result.relativePath, b.result.relativePath);
1775
+ if (fileCmp !== 0)
1776
+ return fileCmp;
1777
+ const startCmp = this.compareNullableNumbersAsc(a.result.startLine, b.result.startLine);
1778
+ if (startCmp !== 0)
1779
+ return startCmp;
1780
+ const labelCmp = this.compareNullableStringsAsc(a.result.symbolLabel, b.result.symbolLabel);
1781
+ if (labelCmp !== 0)
1782
+ return labelCmp;
1783
+ return this.compareNullableStringsAsc(a.result.symbolId, b.result.symbolId);
1784
+ }
1785
+ sortSearchCandidates(candidates, exactMatchFirst, mustMatchesFirst = false) {
1786
+ const topWithoutPinning = candidates.length > 0
1787
+ ? [...candidates].sort((a, b) => this.compareSearchCandidates(a, b, { mustMatchesFirst }))[0]
1788
+ : undefined;
1789
+ candidates.sort((a, b) => this.compareSearchCandidates(a, b, { exactMatchFirst, mustMatchesFirst }));
1790
+ if (!exactMatchFirst || !topWithoutPinning || candidates.length === 0) {
1791
+ return false;
1792
+ }
1793
+ return topWithoutPinning.exactLexicalMatch !== candidates[0].exactLexicalMatch;
1794
+ }
1795
+ isDeclarationSearchGroup(group) {
1796
+ const label = (group.symbolLabel || '').trim().toLowerCase();
1797
+ if (/^(class|type|interface|enum|struct|function|def)\b/.test(label)) {
1798
+ return true;
1799
+ }
1800
+ if (/^(const|let|var)\s+[a-z0-9_$]+\s*=/.test(label)) {
1801
+ return true;
1802
+ }
1803
+ const previewStart = (group.preview || '').slice(0, 240).toLowerCase();
1804
+ return /\b(class|type|interface|enum|struct|function|def)\s+[a-z0-9_]/i.test(previewStart)
1805
+ || /\b(?:const|let|var)\s+[a-z0-9_$]+\s*=\s*(?:async\s+)?function\b/i.test(previewStart)
1806
+ || /\b(?:const|let|var)\s+[a-z0-9_$]+\s*=\s*(?:async\s*)?(?:\([^)]*\)|[a-z_$][\w$]*)\s*=>/i.test(previewStart);
1807
+ }
1808
+ normalizeDeclarationGroupKey(group) {
1809
+ if (!group.file || !group.symbolLabel) {
1810
+ return null;
1811
+ }
1812
+ if (!this.isDeclarationSearchGroup(group)) {
1813
+ return null;
1814
+ }
1815
+ const normalizedLabel = group.symbolLabel
1816
+ .toLowerCase()
1817
+ .replace(/\s+/g, ' ')
1818
+ .trim();
1819
+ return `${group.file}::${normalizedLabel}`;
1820
+ }
1821
+ collapseDuplicateDeclarationGroups(groups) {
1822
+ const deduped = new Map();
1823
+ for (const group of groups) {
1824
+ const key = this.normalizeDeclarationGroupKey(group);
1825
+ if (!key) {
1826
+ deduped.set(`unique:${deduped.size}`, group);
1827
+ continue;
1828
+ }
1829
+ const existing = deduped.get(key);
1830
+ if (!existing) {
1831
+ deduped.set(key, group);
1832
+ continue;
1833
+ }
1834
+ const existingComparable = {
1835
+ result: {
1836
+ relativePath: existing.file,
1837
+ startLine: existing.span.startLine,
1838
+ endLine: existing.span.endLine,
1839
+ symbolId: existing.symbolId || undefined,
1840
+ symbolLabel: existing.symbolLabel || undefined,
1841
+ },
1842
+ baseScore: 0,
1843
+ backendScore: 0,
1844
+ backendScoreKind: 'unknown',
1845
+ fusionScore: 0,
1846
+ lexicalScore: existing.debug?.lexicalScore || 0,
1847
+ finalScore: existing.score,
1848
+ pathCategory: existing.debug?.pathCategory || 'neutral',
1849
+ pathMultiplier: existing.debug?.pathMultiplier || 1,
1850
+ changedFilesMultiplier: existing.debug?.changedFilesMultiplier || 1,
1851
+ passesMatchedMust: existing.debug?.matchesMust === true,
1852
+ exactLexicalMatch: existing.__exactLexicalMatch === true,
1853
+ };
1854
+ const nextComparable = {
1855
+ result: {
1856
+ relativePath: group.file,
1857
+ startLine: group.span.startLine,
1858
+ endLine: group.span.endLine,
1859
+ symbolId: group.symbolId || undefined,
1860
+ symbolLabel: group.symbolLabel || undefined,
1861
+ },
1862
+ baseScore: 0,
1863
+ backendScore: 0,
1864
+ backendScoreKind: 'unknown',
1865
+ fusionScore: 0,
1866
+ lexicalScore: group.debug?.lexicalScore || 0,
1867
+ finalScore: group.score,
1868
+ pathCategory: group.debug?.pathCategory || 'neutral',
1869
+ pathMultiplier: group.debug?.pathMultiplier || 1,
1870
+ changedFilesMultiplier: group.debug?.changedFilesMultiplier || 1,
1871
+ passesMatchedMust: group.debug?.matchesMust === true,
1872
+ exactLexicalMatch: group.__exactLexicalMatch === true,
1873
+ };
1874
+ if (this.compareSearchCandidates(nextComparable, existingComparable) < 0) {
1875
+ deduped.set(key, group);
1876
+ continue;
1877
+ }
1878
+ }
1879
+ return Array.from(deduped.values());
1880
+ }
1522
1881
  buildFallbackGroupId(relativePath, span) {
1523
1882
  const payload = `${relativePath}:${span.startLine}-${span.endLine}`;
1524
1883
  const digest = crypto.createHash('sha1').update(payload, 'utf8').digest('hex').slice(0, 16);
@@ -2600,6 +2959,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2600
2959
  console.log(`${rootTag} 🧠 Using embedding provider: ${encoderEngine.getProvider()} for search`);
2601
2960
  const parsedOperators = this.parseSearchOperators(input.query);
2602
2961
  const semanticQuery = parsedOperators.semanticQuery;
2962
+ const queryPlan = this.buildSearchQueryPlan(semanticQuery);
2603
2963
  const expandedQuery = `${semanticQuery}\nimplementation runtime source entrypoint`;
2604
2964
  const maxAttempts = parsedOperators.must.length > 0 ? 1 + SEARCH_MUST_RETRY_ROUNDS : 1;
2605
2965
  let candidateLimit = Math.max(1, Math.min(SEARCH_MAX_CANDIDATES, Math.max(input.limit * 8, 32)));
@@ -2622,7 +2982,9 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2622
2982
  let attemptsUsed = 0;
2623
2983
  const searchWarningsSet = new Set();
2624
2984
  const passesUsed = new Set();
2985
+ const backendScoreKinds = new Set();
2625
2986
  let scored = [];
2987
+ let exactMatchPinningApplied = false;
2626
2988
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2627
2989
  attemptsUsed = attempt + 1;
2628
2990
  const passDescriptors = [
@@ -2635,7 +2997,16 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2635
2997
  if (this.shouldForceSearchPassFailure(passId)) {
2636
2998
  throw new Error(`FORCED_TEST_SEARCH_PASS_FAILURE:${passId}`);
2637
2999
  }
2638
- return this.context.semanticSearch(effectiveRoot, pass.query, candidateLimit, 0.3);
3000
+ const scorePolicy = queryPlan.scorePolicyKind === 'topk_only'
3001
+ ? { kind: 'topk_only' }
3002
+ : { kind: 'dense_similarity_min', min: 0.3 };
3003
+ return this.context.semanticSearch({
3004
+ codebasePath: effectiveRoot,
3005
+ query: pass.query,
3006
+ topK: candidateLimit,
3007
+ retrievalMode: queryPlan.retrievalMode,
3008
+ scorePolicy
3009
+ });
2639
3010
  }));
2640
3011
  const successfulPasses = [];
2641
3012
  for (let idx = 0; idx < passSettled.length; idx++) {
@@ -2682,21 +3053,40 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2682
3053
  const rrf = passWeight * (1 / (SEARCH_RRF_K + rank));
2683
3054
  const existing = byChunkKey.get(key);
2684
3055
  if (!existing) {
3056
+ const backendScoreKind = typeof result.backendScoreKind === 'string'
3057
+ ? result.backendScoreKind
3058
+ : 'unknown';
3059
+ backendScoreKinds.add(backendScoreKind);
2685
3060
  byChunkKey.set(key, {
2686
3061
  result,
2687
- baseScore: typeof result.score === 'number' ? result.score : 0,
3062
+ baseScore: typeof result.backendScore === 'number'
3063
+ ? result.backendScore
3064
+ : (typeof result.score === 'number' ? result.score : 0),
3065
+ backendScore: typeof result.backendScore === 'number'
3066
+ ? result.backendScore
3067
+ : (typeof result.score === 'number' ? result.score : 0),
3068
+ backendScoreKind,
2688
3069
  fusionScore: rrf,
3070
+ lexicalScore: 0,
2689
3071
  finalScore: 0,
2690
3072
  pathCategory: 'neutral',
2691
3073
  pathMultiplier: 1.0,
2692
3074
  changedFilesMultiplier: 1.0,
2693
3075
  passesMatchedMust: false,
3076
+ exactLexicalMatch: false,
2694
3077
  });
2695
3078
  }
2696
3079
  else {
2697
3080
  existing.fusionScore += rrf;
2698
- if (typeof result.score === 'number') {
2699
- existing.baseScore = Math.max(existing.baseScore, result.score);
3081
+ const nextScore = typeof result.backendScore === 'number'
3082
+ ? result.backendScore
3083
+ : (typeof result.score === 'number' ? result.score : undefined);
3084
+ if (typeof nextScore === 'number') {
3085
+ existing.baseScore = Math.max(existing.baseScore, nextScore);
3086
+ existing.backendScore = Math.max(existing.backendScore, nextScore);
3087
+ }
3088
+ if (typeof result.backendScoreKind === 'string') {
3089
+ backendScoreKinds.add(result.backendScoreKind);
2700
3090
  }
2701
3091
  }
2702
3092
  }
@@ -2751,34 +3141,24 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2751
3141
  candidate.pathMultiplier = pathMultiplier;
2752
3142
  candidate.changedFilesMultiplier = changedFilesMultiplier;
2753
3143
  candidate.passesMatchedMust = matchesMust;
2754
- candidate.finalScore = candidate.fusionScore * pathMultiplier * changedFilesMultiplier;
3144
+ const lexicalEvidence = this.scoreCandidateLexicalEvidence(queryPlan, candidate.result);
3145
+ candidate.lexicalScore = lexicalEvidence.score;
3146
+ candidate.exactLexicalMatch = lexicalEvidence.exactLexicalMatch;
3147
+ candidate.finalScore = (candidate.fusionScore + candidate.lexicalScore) * pathMultiplier * changedFilesMultiplier;
2755
3148
  scoredAttempt.push(candidate);
2756
3149
  }
2757
3150
  searchDiagnostics.resultsBeforeFilter = beforeFilter;
2758
3151
  searchDiagnostics.resultsAfterFilter = scoredAttempt.length;
2759
3152
  filterSummary = attemptFilterSummary;
2760
3153
  scored = scoredAttempt;
2761
- scored.sort((a, b) => {
2762
- if (b.finalScore !== a.finalScore)
2763
- return b.finalScore - a.finalScore;
2764
- const fileCmp = this.compareNullableStringsAsc(a.result.relativePath, b.result.relativePath);
2765
- if (fileCmp !== 0)
2766
- return fileCmp;
2767
- const startCmp = this.compareNullableNumbersAsc(a.result.startLine, b.result.startLine);
2768
- if (startCmp !== 0)
2769
- return startCmp;
2770
- const labelCmp = this.compareNullableStringsAsc(a.result.symbolLabel, b.result.symbolLabel);
2771
- if (labelCmp !== 0)
2772
- return labelCmp;
2773
- return this.compareNullableStringsAsc(a.result.symbolId, b.result.symbolId);
2774
- });
3154
+ exactMatchPinningApplied = this.sortSearchCandidates(scored, queryPlan.exactMatchPinningEnabled, parsedOperators.must.length > 0) || exactMatchPinningApplied;
2775
3155
  if (parsedOperators.must.length === 0 || scored.length >= input.limit || attempt === maxAttempts - 1 || candidateLimit >= SEARCH_MAX_CANDIDATES) {
2776
3156
  break;
2777
3157
  }
2778
3158
  candidateLimit = Math.min(SEARCH_MAX_CANDIDATES, Math.max(candidateLimit + 1, candidateLimit * SEARCH_MUST_RETRY_MULTIPLIER));
2779
3159
  }
2780
3160
  const searchWarnings = Array.from(searchWarningsSet);
2781
- const rerankDecision = this.resolveRerankDecision(input.scope);
3161
+ const rerankDecision = this.resolveRerankDecision(input.scope, queryPlan);
2782
3162
  let rerankerApplied = false;
2783
3163
  let rerankerAttempted = false;
2784
3164
  let rerankerFailurePhase;
@@ -2823,22 +3203,9 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2823
3203
  }
2824
3204
  const rerankRrf = 1 / (SEARCH_RERANK_RRF_K + rank);
2825
3205
  rerankSlice[idx].fusionScore += SEARCH_RERANK_WEIGHT * rerankRrf;
2826
- rerankSlice[idx].finalScore = rerankSlice[idx].fusionScore * rerankSlice[idx].pathMultiplier * rerankSlice[idx].changedFilesMultiplier;
3206
+ rerankSlice[idx].finalScore = (rerankSlice[idx].fusionScore + rerankSlice[idx].lexicalScore) * rerankSlice[idx].pathMultiplier * rerankSlice[idx].changedFilesMultiplier;
2827
3207
  }
2828
- scored.sort((a, b) => {
2829
- if (b.finalScore !== a.finalScore)
2830
- return b.finalScore - a.finalScore;
2831
- const fileCmp = this.compareNullableStringsAsc(a.result.relativePath, b.result.relativePath);
2832
- if (fileCmp !== 0)
2833
- return fileCmp;
2834
- const startCmp = this.compareNullableNumbersAsc(a.result.startLine, b.result.startLine);
2835
- if (startCmp !== 0)
2836
- return startCmp;
2837
- const labelCmp = this.compareNullableStringsAsc(a.result.symbolLabel, b.result.symbolLabel);
2838
- if (labelCmp !== 0)
2839
- return labelCmp;
2840
- return this.compareNullableStringsAsc(a.result.symbolId, b.result.symbolId);
2841
- });
3208
+ exactMatchPinningApplied = this.sortSearchCandidates(scored, rerankDecision.exactMatchPinningEnabled, parsedOperators.must.length > 0) || exactMatchPinningApplied;
2842
3209
  rerankerApplied = true;
2843
3210
  }
2844
3211
  catch {
@@ -2859,6 +3226,18 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2859
3226
  const finalizedSearchWarnings = Array.from(new Set(searchWarnings)).sort();
2860
3227
  const debugHintBase = input.debug
2861
3228
  ? {
3229
+ queryIntent: {
3230
+ classification: queryPlan.intent,
3231
+ confidence: queryPlan.confidence,
3232
+ reasons: [...queryPlan.reasons],
3233
+ lexicalTerms: queryPlan.lexicalTerms.map((term) => term.value),
3234
+ semanticQuery,
3235
+ },
3236
+ retrieval: {
3237
+ mode: queryPlan.retrievalMode,
3238
+ scorePolicyKind: queryPlan.scorePolicyKind,
3239
+ backendScoreKinds: Array.from(backendScoreKinds).sort(),
3240
+ },
2862
3241
  passesUsed: Array.from(passesUsed).sort(),
2863
3242
  candidateLimit,
2864
3243
  mustRetry: {
@@ -2883,11 +3262,14 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2883
3262
  rerank: {
2884
3263
  enabledByPolicy: rerankDecision.enabledByPolicy,
2885
3264
  skippedByScopeDocs: rerankDecision.skippedByScopeDocs,
3265
+ skippedByIdentifierIntent: rerankDecision.skippedByIdentifierIntent,
2886
3266
  capabilityPresent: rerankDecision.capabilityPresent,
2887
3267
  rerankerPresent: rerankDecision.rerankerPresent,
2888
3268
  enabled: rerankDecision.enabled,
2889
3269
  attempted: rerankerAttempted,
2890
3270
  applied: rerankerApplied,
3271
+ exactMatchPinningEnabled: rerankDecision.exactMatchPinningEnabled,
3272
+ exactMatchPinningApplied,
2891
3273
  candidatesIn: rerankerCandidatesIn,
2892
3274
  candidatesReranked: rerankerCandidatesReranked,
2893
3275
  topK: SEARCH_RERANK_TOP_K,
@@ -2918,10 +3300,14 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2918
3300
  debug: {
2919
3301
  baseScore: candidate.baseScore,
2920
3302
  fusionScore: candidate.fusionScore,
3303
+ lexicalScore: candidate.lexicalScore,
2921
3304
  pathMultiplier: candidate.pathMultiplier,
2922
3305
  pathCategory: candidate.pathCategory,
2923
3306
  changedFilesMultiplier: candidate.changedFilesMultiplier,
2924
- matchesMust: candidate.passesMatchedMust
3307
+ matchesMust: candidate.passesMatchedMust,
3308
+ exactLexicalMatch: candidate.exactLexicalMatch,
3309
+ backendScore: candidate.backendScore,
3310
+ backendScoreKind: candidate.backendScoreKind,
2925
3311
  }
2926
3312
  } : {})
2927
3313
  }));
@@ -2986,23 +3372,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
2986
3372
  const sidecarReadyForOutline = Boolean(sidecarInfo && sidecarInfo.version === 'v3');
2987
3373
  const groupedResults = [];
2988
3374
  for (const group of groups.values()) {
2989
- group.chunks.sort((a, b) => {
2990
- if (a.passesMatchedMust !== b.passesMatchedMust) {
2991
- return a.passesMatchedMust ? -1 : 1;
2992
- }
2993
- if (b.finalScore !== a.finalScore)
2994
- return b.finalScore - a.finalScore;
2995
- const fileCmp = this.compareNullableStringsAsc(a.result.relativePath, b.result.relativePath);
2996
- if (fileCmp !== 0)
2997
- return fileCmp;
2998
- const startCmp = this.compareNullableNumbersAsc(a.result.startLine, b.result.startLine);
2999
- if (startCmp !== 0)
3000
- return startCmp;
3001
- const labelCmp = this.compareNullableStringsAsc(a.result.symbolLabel, b.result.symbolLabel);
3002
- if (labelCmp !== 0)
3003
- return labelCmp;
3004
- return this.compareNullableStringsAsc(a.result.symbolId, b.result.symbolId);
3005
- });
3375
+ exactMatchPinningApplied = this.sortSearchCandidates(group.chunks, queryPlan.exactMatchPinningEnabled, parsedOperators.must.length > 0) || exactMatchPinningApplied;
3006
3376
  const representative = group.chunks[0];
3007
3377
  const spanStart = Math.min(...group.chunks.map((c) => c.result.startLine || 0));
3008
3378
  const spanEnd = Math.max(...group.chunks.map((c) => c.result.endLine || 0));
@@ -3037,19 +3407,28 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
3037
3407
  callGraphHint,
3038
3408
  ...(navigationFallback ? { navigationFallback } : {}),
3039
3409
  preview: truncateContent(String(representative.result.content || ''), 4000),
3410
+ __exactLexicalMatch: representative.exactLexicalMatch,
3040
3411
  ...(input.debug ? {
3041
3412
  debug: {
3042
3413
  representativeChunkCount: group.chunks.length,
3043
3414
  pathCategory: representative.pathCategory,
3044
3415
  pathMultiplier: representative.pathMultiplier,
3045
3416
  topChunkScore: representative.finalScore,
3417
+ lexicalScore: representative.lexicalScore,
3046
3418
  changedFilesMultiplier: representative.changedFilesMultiplier,
3047
- matchesMust: representative.passesMatchedMust
3419
+ matchesMust: representative.passesMatchedMust,
3420
+ exactLexicalMatch: representative.exactLexicalMatch,
3048
3421
  }
3049
3422
  } : {})
3050
3423
  });
3051
3424
  }
3052
- groupedResults.sort((a, b) => {
3425
+ const rankedGroupedResults = (queryPlan.referenceSeeking || queryPlan.intent === 'identifier')
3426
+ ? this.collapseDuplicateDeclarationGroups(groupedResults)
3427
+ : groupedResults;
3428
+ rankedGroupedResults.sort((a, b) => {
3429
+ if (queryPlan.exactMatchPinningEnabled && a.__exactLexicalMatch !== b.__exactLexicalMatch) {
3430
+ return a.__exactLexicalMatch ? -1 : 1;
3431
+ }
3053
3432
  if (b.score !== a.score)
3054
3433
  return b.score - a.score;
3055
3434
  const fileCmp = a.file.localeCompare(b.file);
@@ -3063,7 +3442,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
3063
3442
  return labelCmp;
3064
3443
  return this.compareNullableStringsAsc(a.symbolId, b.symbolId);
3065
3444
  });
3066
- const diversityApplied = this.applyGroupDiversity(groupedResults, input.limit, input.groupBy);
3445
+ const diversityApplied = this.applyGroupDiversity(rankedGroupedResults, input.limit, input.groupBy);
3067
3446
  const visibleGroupedResults = diversityApplied.selected;
3068
3447
  const noiseMitigationHint = this.buildNoiseMitigationHint(effectiveRoot, visibleGroupedResults.map((result) => result.file));
3069
3448
  const responseHints = {};
@@ -3093,7 +3472,7 @@ To force rebuild from scratch: call manage_index with {"action":"create","path":
3093
3472
  freshnessDecision,
3094
3473
  ...(finalizedSearchWarnings.length > 0 ? { warnings: finalizedSearchWarnings } : {}),
3095
3474
  ...(Object.keys(responseHints).length > 0 ? { hints: responseHints } : {}),
3096
- results: visibleGroupedResults
3475
+ results: visibleGroupedResults.map(({ __exactLexicalMatch: _exactLexicalMatch, ...result }) => result)
3097
3476
  };
3098
3477
  await this.touchWatchedCodebase(effectiveRoot);
3099
3478
  return {
@@ -33,10 +33,14 @@ export interface SearchChunkResult {
33
33
  debug?: {
34
34
  baseScore: number;
35
35
  fusionScore: number;
36
+ lexicalScore: number;
36
37
  pathMultiplier: number;
37
38
  pathCategory: string;
38
39
  changedFilesMultiplier?: number;
39
40
  matchesMust?: boolean;
41
+ exactLexicalMatch: boolean;
42
+ backendScore?: number;
43
+ backendScoreKind?: "dense_similarity" | "lexical_rank" | "rrf_fusion" | "unknown";
40
44
  };
41
45
  }
42
46
  export interface SearchGroupResult {
@@ -59,8 +63,10 @@ export interface SearchGroupResult {
59
63
  pathCategory: string;
60
64
  pathMultiplier: number;
61
65
  topChunkScore: number;
66
+ lexicalScore: number;
62
67
  changedFilesMultiplier?: number;
63
68
  matchesMust?: boolean;
69
+ exactLexicalMatch: boolean;
64
70
  };
65
71
  }
66
72
  export interface SearchNavigationFallbackContext {
@@ -117,6 +123,18 @@ export interface SearchOperatorSummary {
117
123
  exclude: string[];
118
124
  }
119
125
  export interface SearchDebugHint {
126
+ queryIntent: {
127
+ classification: "identifier" | "semantic" | "mixed" | "uncertain";
128
+ confidence: "high" | "medium" | "low";
129
+ reasons: string[];
130
+ lexicalTerms: string[];
131
+ semanticQuery: string;
132
+ };
133
+ retrieval: {
134
+ mode: "dense" | "lexical" | "hybrid";
135
+ scorePolicyKind: "dense_similarity_min" | "topk_only";
136
+ backendScoreKinds: Array<"dense_similarity" | "lexical_rank" | "rrf_fusion" | "unknown">;
137
+ };
120
138
  passesUsed: string[];
121
139
  candidateLimit: number;
122
140
  mustRetry: {
@@ -156,11 +174,14 @@ export interface SearchDebugHint {
156
174
  rerank?: {
157
175
  enabledByPolicy: boolean;
158
176
  skippedByScopeDocs: boolean;
177
+ skippedByIdentifierIntent: boolean;
159
178
  capabilityPresent: boolean;
160
179
  rerankerPresent: boolean;
161
180
  enabled: boolean;
162
181
  attempted: boolean;
163
182
  applied: boolean;
183
+ exactMatchPinningEnabled: boolean;
184
+ exactMatchPinningApplied: boolean;
164
185
  candidatesIn: number;
165
186
  candidatesReranked: number;
166
187
  errorCode?: "RERANKER_FAILED";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zokizuan/satori-mcp",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "MCP server for Satori with agent-safe semantic search and indexing",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,7 @@
14
14
  "ignore": "^7.0.5",
15
15
  "zod": "^3.25.55",
16
16
  "zod-to-json-schema": "^3.25.1",
17
- "@zokizuan/satori-core": "1.2.0"
17
+ "@zokizuan/satori-core": "1.3.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.0.0",