@skein-code/cli 0.3.18 → 0.3.19

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/README.md CHANGED
@@ -441,10 +441,24 @@ npm run benchmark:context -- \
441
441
  ```
442
442
 
443
443
  It reports Recall@5/10/20, MRR, useful-token ratio, stale-hit rate, and cold,
444
- incremental, and warm-query latency. The included fixture only validates the
445
- metric pipeline; it is not evidence for performance on a production repository.
446
- `--fresh-index` deletes and rebuilds that workspace's local index, so use it only
447
- where rebuilding the index is intended.
444
+ incremental, and warm-query latency. `context-benchmark-v2` covers TypeScript,
445
+ Python, SQL, CJK, Markdown, mixed-language queries, and a TypeScript import
446
+ neighbor that has no direct lexical match. Its checked-in thresholds fail the
447
+ command instead of allowing a retrieval change to lower the quality bar.
448
+
449
+ The persisted local index now stores content-addressed TypeScript compiler AST
450
+ facts for definitions, calls, and relative imports. Matching definitions can
451
+ expand a query and import/call neighbors may receive a bounded graph score;
452
+ Python and SQL continue to use explicit syntax-aware fallbacks. Search and
453
+ context JSON expose the index generation, file hash, matched/expanded terms,
454
+ and bm25/path/symbol/phrase/graph breakdown without persisting the query or an
455
+ additional copy of source. Index schema v2 artifacts rebuild as v3 rather than
456
+ being trusted after the parser contract changes.
457
+
458
+ The included fixture is a deterministic regression gate, not evidence for
459
+ performance on every production repository. `--fresh-index` deletes and
460
+ rebuilds that workspace's local index, so use it only where rebuilding the
461
+ index is intended.
448
462
 
449
463
  ## Safety model
450
464
 
package/dist/cli.js CHANGED
@@ -220,7 +220,7 @@ function isSqliteBusy(error) {
220
220
  // package.json
221
221
  var package_default = {
222
222
  name: "@skein-code/cli",
223
- version: "0.3.18",
223
+ version: "0.3.19",
224
224
  description: "A context-first, model-agnostic coding agent with an auditable terminal workspace.",
225
225
  type: "module",
226
226
  license: "MIT",
@@ -236,6 +236,9 @@ var package_default = {
236
236
  },
237
237
  skein: {
238
238
  releaseNotes: [
239
+ "Local index v3 records TypeScript AST definitions, calls, and relative import adjacency for graph-assisted retrieval",
240
+ "Context hits expose generation, content hash, matched terms, and bm25/path/symbol/phrase/graph score provenance",
241
+ "Context benchmark v2 locks multilingual recall, MRR, useful-token, stale-hit, incremental-index, and warm-latency gates",
239
242
  "Large MCP catalogs now disclose at most eight request-relevant schemas per run while preserving network permissions",
240
243
  "Token Ledger receipts report deferred progressive schemas and selected definitions remain stable across tool turns",
241
244
  "A deterministic Token Economy replay benchmark guards evidence, output-firewall, and no-progress invariants",
@@ -301,6 +304,7 @@ var package_default = {
301
304
  react: "^19.2.3",
302
305
  "string-width": "^8.2.2",
303
306
  "strip-ansi": "^7.2.0",
307
+ typescript: "^5.9.3",
304
308
  yaml: "^2.8.2",
305
309
  zod: "^4.4.3"
306
310
  },
@@ -310,7 +314,6 @@ var package_default = {
310
314
  "@types/react": "^19.2.7",
311
315
  tsup: "^8.5.1",
312
316
  tsx: "^4.21.0",
313
- typescript: "^5.9.3",
314
317
  vitest: "^4.0.18"
315
318
  },
316
319
  overrides: {
@@ -2610,8 +2613,9 @@ function countExplicitPaths(value) {
2610
2613
  // src/context/local-index.ts
2611
2614
  import { createHash as createHash6 } from "node:crypto";
2612
2615
  import { lstat as lstat8, readFile as readFile3, stat as stat3 } from "node:fs/promises";
2613
- import { basename as basename5, dirname as dirname7, extname as extname2, isAbsolute as isAbsolute3, join as join7, relative as relative5, resolve as resolve8, sep as sep4 } from "node:path";
2616
+ import { basename as basename5, dirname as dirname7, extname as extname2, isAbsolute as isAbsolute3, join as join7, posix, relative as relative5, resolve as resolve8, sep as sep4 } from "node:path";
2614
2617
  import fg from "fast-glob";
2618
+ import ts from "typescript";
2615
2619
  import { z as z3 } from "zod";
2616
2620
 
2617
2621
  // src/tools/workspace.ts
@@ -3063,10 +3067,13 @@ var indexedFileSchema = z3.object({
3063
3067
  ctimeMs: z3.number().optional(),
3064
3068
  size: z3.number().nonnegative(),
3065
3069
  contentHash: contentHashSchema,
3066
- chunks: z3.array(indexedChunkSchema)
3070
+ chunks: z3.array(indexedChunkSchema),
3071
+ definitions: z3.array(z3.string()),
3072
+ references: z3.array(z3.string()),
3073
+ imports: z3.array(z3.string())
3067
3074
  }).strict();
3068
3075
  var localIndexSchema = z3.object({
3069
- version: z3.literal(2),
3076
+ version: z3.literal(3),
3070
3077
  createdAt: z3.string(),
3071
3078
  generation: z3.string().min(1),
3072
3079
  roots: z3.array(z3.string()),
@@ -3112,6 +3119,8 @@ var LocalContextIndex = class {
3112
3119
  index;
3113
3120
  workspace;
3114
3121
  queryCache = /* @__PURE__ */ new Map();
3122
+ queryCacheHits = 0;
3123
+ queryCacheMisses = 0;
3115
3124
  fingerprintCache;
3116
3125
  dirtyPaths = /* @__PURE__ */ new Set();
3117
3126
  refreshState = "current";
@@ -3257,7 +3266,7 @@ var LocalContextIndex = class {
3257
3266
  const nextFiles = [...files.values()].sort((left, right) => left.absolutePath.localeCompare(right.absolutePath));
3258
3267
  const generation = createGeneration(nextFiles);
3259
3268
  this.index = {
3260
- version: 2,
3269
+ version: 3,
3261
3270
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3262
3271
  generation,
3263
3272
  roots: this.roots,
@@ -3309,6 +3318,8 @@ var LocalContextIndex = class {
3309
3318
  files: this.index?.files.length ?? 0,
3310
3319
  chunks: this.index?.files.reduce((total, file) => total + file.chunks.length, 0) ?? 0,
3311
3320
  queryCacheEntries: this.queryCache.size,
3321
+ queryCacheHits: this.queryCacheHits,
3322
+ queryCacheMisses: this.queryCacheMisses,
3312
3323
  refreshState: this.refreshState,
3313
3324
  dirtyPaths: this.dirtyPaths.size,
3314
3325
  ...this.refreshError ? { refreshError: this.refreshError } : {},
@@ -3391,12 +3402,13 @@ var LocalContextIndex = class {
3391
3402
  ctimeMs: info.ctimeMs,
3392
3403
  size: info.size,
3393
3404
  contentHash,
3394
- chunks: chunkFile(safeItem, content)
3405
+ chunks: chunkFile(safeItem, content),
3406
+ ...extractSourceFacts(safeItem.path, content)
3395
3407
  });
3396
3408
  }
3397
3409
  const generation = createGeneration(files);
3398
3410
  this.index = {
3399
- version: 2,
3411
+ version: 3,
3400
3412
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3401
3413
  generation,
3402
3414
  roots: this.roots,
@@ -3461,7 +3473,8 @@ var LocalContextIndex = class {
3461
3473
  ctimeMs: info.ctimeMs,
3462
3474
  size: info.size,
3463
3475
  contentHash: hashContent(content),
3464
- chunks: chunkFile(safeItem, content)
3476
+ chunks: chunkFile(safeItem, content),
3477
+ ...extractSourceFacts(safeItem.path, content)
3465
3478
  };
3466
3479
  } catch {
3467
3480
  return void 0;
@@ -3484,7 +3497,8 @@ var LocalContextIndex = class {
3484
3497
  path: file.path,
3485
3498
  absolutePath: file.absolutePath
3486
3499
  }, content);
3487
- if (!chunksMatch(expectedChunks, file.chunks)) return false;
3500
+ const expectedFacts = extractSourceFacts(file.path, content);
3501
+ if (!chunksMatch(expectedChunks, file.chunks) || !sameStrings(expectedFacts.definitions, file.definitions) || !sameStrings(expectedFacts.references, file.references) || !sameStrings(expectedFacts.imports, file.imports)) return false;
3488
3502
  } catch {
3489
3503
  return false;
3490
3504
  }
@@ -3524,10 +3538,14 @@ var LocalContextIndex = class {
3524
3538
  return matches.length === 1 ? { root, path, absolutePath } : void 0;
3525
3539
  }
3526
3540
  rank(query, topK) {
3527
- const chunks = (this.index?.files ?? []).flatMap((file) => file.chunks);
3541
+ const index = this.index;
3542
+ const files = index?.files ?? [];
3543
+ const chunks = files.flatMap((file) => file.chunks);
3528
3544
  if (!chunks.length) return [];
3529
- const terms = [...new Set(tokenize(query))];
3530
- if (!terms.length) return [];
3545
+ const directTerms = [...new Set(tokenize(query))];
3546
+ if (!directTerms.length || !index) return [];
3547
+ const expandedTerms = expandQueryTerms(files, directTerms);
3548
+ const terms = [.../* @__PURE__ */ new Set([...directTerms, ...expandedTerms])];
3531
3549
  const queryTerms = new Set(terms);
3532
3550
  const documentFrequency = new Map([...queryTerms].map((term) => [term, 0]));
3533
3551
  for (const chunk of chunks) {
@@ -3536,22 +3554,51 @@ var LocalContextIndex = class {
3536
3554
  }
3537
3555
  }
3538
3556
  const averageLength = chunks.reduce((sum, chunk) => sum + chunk.tokens.length, 0) / Math.max(chunks.length, 1);
3539
- return chunks.map((chunk) => ({ chunk, score: scoreChunk(
3540
- chunk,
3541
- terms,
3542
- query,
3543
- documentFrequency,
3544
- chunks.length,
3545
- averageLength
3546
- ) })).filter(({ score }) => score > 0).sort((left, right) => right.score - left.score || left.chunk.absolutePath.localeCompare(right.chunk.absolutePath)).slice(0, topK).map(({ chunk, score }) => ({
3547
- path: chunk.absolutePath,
3548
- startLine: chunk.startLine,
3549
- endLine: chunk.endLine,
3550
- content: chunk.content,
3551
- score,
3552
- source: "local-bm25+path+symbol",
3553
- ...chunk.symbol ? { symbol: chunk.symbol } : {}
3554
- }));
3557
+ const filesByPath = new Map(files.map((file) => [file.absolutePath, file]));
3558
+ const graphBoosts = buildGraphBoosts(files, directTerms);
3559
+ const coverageTerms = directTerms.filter((term) => !retrievalStopWords.has(term));
3560
+ const scored = chunks.map((chunk) => {
3561
+ const lexical = scoreChunk(
3562
+ chunk,
3563
+ terms,
3564
+ query,
3565
+ documentFrequency,
3566
+ chunks.length,
3567
+ averageLength
3568
+ );
3569
+ const graph = graphBoosts.get(chunk.absolutePath)?.score ?? 0;
3570
+ const score = lexical.bm25 + lexical.path + lexical.symbol + lexical.phrase + graph;
3571
+ return { chunk, lexical, graph, score };
3572
+ }).filter(({ score }) => score > 0).sort((left, right) => right.score - left.score || left.chunk.absolutePath.localeCompare(right.chunk.absolutePath));
3573
+ const covered = scored.filter(({ lexical, graph }) => graph > 0 || hasSufficientQueryCoverage(lexical.matchedTerms, coverageTerms));
3574
+ return (covered.length ? covered : scored).slice(0, topK).map(({ chunk, lexical, graph, score }) => {
3575
+ const file = filesByPath.get(chunk.absolutePath);
3576
+ const breakdown = {
3577
+ bm25: roundScore(lexical.bm25),
3578
+ path: roundScore(lexical.path),
3579
+ symbol: roundScore(lexical.symbol),
3580
+ phrase: roundScore(lexical.phrase),
3581
+ graph: roundScore(graph),
3582
+ total: roundScore(score)
3583
+ };
3584
+ const provenance = {
3585
+ generation: index.generation,
3586
+ contentHash: file?.contentHash ?? hashContent(chunk.content),
3587
+ matchedTerms: lexical.matchedTerms.slice(0, 16),
3588
+ expandedTerms: expandedTerms.slice(0, 16),
3589
+ score: breakdown
3590
+ };
3591
+ return {
3592
+ path: chunk.absolutePath,
3593
+ startLine: chunk.startLine,
3594
+ endLine: chunk.endLine,
3595
+ content: chunk.content,
3596
+ score,
3597
+ source: "local-bm25+path+symbol+graph",
3598
+ ...chunk.symbol ? { symbol: chunk.symbol } : {},
3599
+ provenance
3600
+ };
3601
+ });
3555
3602
  }
3556
3603
  async hitsAreCurrent(hits) {
3557
3604
  const files = new Map((this.index?.files ?? []).map((file) => [file.absolutePath, file]));
@@ -3575,10 +3622,17 @@ var LocalContextIndex = class {
3575
3622
  }
3576
3623
  getCachedHits(query, topK) {
3577
3624
  const generation = this.index?.generation;
3578
- if (!generation) return void 0;
3625
+ if (!generation) {
3626
+ this.queryCacheMisses += 1;
3627
+ return void 0;
3628
+ }
3579
3629
  const key = `${generation}\0${topK}\0${query}`;
3580
3630
  const cached = this.queryCache.get(key);
3581
- if (!cached) return void 0;
3631
+ if (!cached) {
3632
+ this.queryCacheMisses += 1;
3633
+ return void 0;
3634
+ }
3635
+ this.queryCacheHits += 1;
3582
3636
  this.queryCache.delete(key);
3583
3637
  this.queryCache.set(key, cached);
3584
3638
  return cloneHits(cached);
@@ -3674,6 +3728,9 @@ function chunksMatch(expected, actual) {
3674
3728
  return Boolean(candidate) && chunk.id === candidate?.id && chunk.root === candidate.root && chunk.path === candidate.path && chunk.absolutePath === candidate.absolutePath && chunk.startLine === candidate.startLine && chunk.endLine === candidate.endLine && chunk.content === candidate.content && chunk.symbol === candidate.symbol && chunk.tokens.length === candidate.tokens.length && chunk.tokens.every((token, tokenIndex) => token === candidate.tokens[tokenIndex]);
3675
3729
  });
3676
3730
  }
3731
+ function sameStrings(expected, actual) {
3732
+ return expected.length === actual.length && expected.every((value, index) => value === actual[index]);
3733
+ }
3677
3734
  function existingBuildResult(status, started) {
3678
3735
  if (!status.available || !status.generation) {
3679
3736
  throw new Error("The existing local context index is unavailable.");
@@ -3728,7 +3785,8 @@ function packContextHits(hits, roots, maxTokens, engine) {
3728
3785
  const text = selected.map((hit) => {
3729
3786
  const shownPath = workspaceAliasPath(hit.path, roots);
3730
3787
  const symbol = hit.symbol ? ` symbol="${escapeAttribute(hit.symbol)}"` : "";
3731
- return `<code path="${escapeAttribute(shownPath)}" lines="${hit.startLine}-${hit.endLine}" score="${hit.score.toFixed(3)}"${symbol}>
3788
+ const provenance = hit.provenance ? ` source="${escapeAttribute(hit.source)}" hash="${hit.provenance.contentHash.slice(0, 12)}" graph-score="${hit.provenance.score.graph.toFixed(3)}"` : "";
3789
+ return `<code path="${escapeAttribute(shownPath)}" lines="${hit.startLine}-${hit.endLine}" score="${hit.score.toFixed(3)}"${symbol}${provenance}>
3732
3790
  ${hit.content}
3733
3791
  </code>`;
3734
3792
  }).join("\n\n");
@@ -3744,7 +3802,17 @@ ${hit.content}
3744
3802
  };
3745
3803
  }
3746
3804
  function cloneHits(hits) {
3747
- return hits.map((hit) => ({ ...hit }));
3805
+ return hits.map((hit) => ({
3806
+ ...hit,
3807
+ ...hit.provenance ? {
3808
+ provenance: {
3809
+ ...hit.provenance,
3810
+ matchedTerms: [...hit.provenance.matchedTerms],
3811
+ expandedTerms: [...hit.provenance.expandedTerms],
3812
+ score: { ...hit.provenance.score }
3813
+ }
3814
+ } : {}
3815
+ }));
3748
3816
  }
3749
3817
  function cloneDuplicationBaseline(baseline) {
3750
3818
  return {
@@ -3846,6 +3914,109 @@ function detectSymbol(lines) {
3846
3914
  }
3847
3915
  return void 0;
3848
3916
  }
3917
+ function extractSourceFacts(path, content) {
3918
+ const extension = extname2(path).toLocaleLowerCase();
3919
+ if ([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(extension)) {
3920
+ return extractTypeScriptFacts(path, content);
3921
+ }
3922
+ if (extension === ".py") return extractPythonFacts(content);
3923
+ if (extension === ".sql") return extractSqlFacts(content);
3924
+ return { definitions: [], references: [], imports: [] };
3925
+ }
3926
+ function extractTypeScriptFacts(path, content) {
3927
+ const definitions = /* @__PURE__ */ new Set();
3928
+ const references = /* @__PURE__ */ new Set();
3929
+ const imports = /* @__PURE__ */ new Set();
3930
+ const source = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, false, scriptKind(path));
3931
+ const addName = (name) => {
3932
+ if (!name) return;
3933
+ if (ts.isIdentifier(name)) definitions.add(name.text);
3934
+ else if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) definitions.add(name.text);
3935
+ else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
3936
+ for (const element of name.elements) if (ts.isBindingElement(element)) addName(element.name);
3937
+ }
3938
+ };
3939
+ const addCallReference = (expression) => {
3940
+ if (ts.isIdentifier(expression)) references.add(expression.text);
3941
+ else if (ts.isPropertyAccessExpression(expression)) references.add(expression.name.text);
3942
+ else if (ts.isElementAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
3943
+ references.add(expression.expression.text);
3944
+ }
3945
+ };
3946
+ const visit = (node) => {
3947
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
3948
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) imports.add(node.moduleSpecifier.text);
3949
+ } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && node.moduleReference.expression && ts.isStringLiteral(node.moduleReference.expression)) {
3950
+ imports.add(node.moduleReference.expression.text);
3951
+ } else if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node) || ts.isModuleDeclaration(node)) {
3952
+ addName(node.name);
3953
+ } else if (ts.isVariableDeclaration(node) || ts.isBindingElement(node) || ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) {
3954
+ addName(node.name);
3955
+ } else if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
3956
+ addCallReference(node.expression);
3957
+ }
3958
+ ts.forEachChild(node, visit);
3959
+ };
3960
+ visit(source);
3961
+ return {
3962
+ definitions: sortedValues(definitions),
3963
+ references: sortedValues(references),
3964
+ imports: sortedValues(imports)
3965
+ };
3966
+ }
3967
+ function scriptKind(path) {
3968
+ switch (extname2(path).toLocaleLowerCase()) {
3969
+ case ".tsx":
3970
+ return ts.ScriptKind.TSX;
3971
+ case ".jsx":
3972
+ return ts.ScriptKind.JSX;
3973
+ case ".js":
3974
+ case ".mjs":
3975
+ case ".cjs":
3976
+ return ts.ScriptKind.JS;
3977
+ default:
3978
+ return ts.ScriptKind.TS;
3979
+ }
3980
+ }
3981
+ function extractPythonFacts(content) {
3982
+ const definitions = /* @__PURE__ */ new Set();
3983
+ const references = /* @__PURE__ */ new Set();
3984
+ const imports = /* @__PURE__ */ new Set();
3985
+ for (const line of content.split("\n")) {
3986
+ const definition = line.match(/^\s*(?:async\s+)?(?:def|class)\s+([\p{L}_][\p{L}\p{N}_]*)/u);
3987
+ if (definition?.[1]) definitions.add(definition[1]);
3988
+ const imported = line.match(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/u);
3989
+ if (imported?.[1] || imported?.[2]) imports.add(imported[1] ?? imported[2] ?? "");
3990
+ for (const call of line.matchAll(/([\p{L}_][\p{L}\p{N}_]*)\s*\(/gu)) {
3991
+ if (call[1] && !["def", "class", "if", "for", "while"].includes(call[1])) references.add(call[1]);
3992
+ }
3993
+ }
3994
+ for (const definition of definitions) references.delete(definition);
3995
+ return {
3996
+ definitions: sortedValues(definitions),
3997
+ references: sortedValues(references),
3998
+ imports: sortedValues(imports)
3999
+ };
4000
+ }
4001
+ function extractSqlFacts(content) {
4002
+ const definitions = /* @__PURE__ */ new Set();
4003
+ const references = /* @__PURE__ */ new Set();
4004
+ const imports = /* @__PURE__ */ new Set();
4005
+ for (const definition of content.matchAll(/\bcreate\s+(?:table|view|function|procedure)\s+(?:if\s+not\s+exists\s+)?([\w.]+)/giu)) {
4006
+ if (definition[1]) definitions.add(definition[1]);
4007
+ }
4008
+ for (const reference2 of content.matchAll(/\b(?:from|join|update|into)\s+([\w.]+)/giu)) {
4009
+ if (reference2[1]) references.add(reference2[1]);
4010
+ }
4011
+ return {
4012
+ definitions: sortedValues(definitions),
4013
+ references: sortedValues(references),
4014
+ imports: sortedValues(imports)
4015
+ };
4016
+ }
4017
+ function sortedValues(values) {
4018
+ return [...values].filter(Boolean).sort((left, right) => left.localeCompare(right));
4019
+ }
3849
4020
  function tokenize(input2) {
3850
4021
  const normalized = input2.replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/([\p{Ll}])([\p{Lu}])/gu, "$1 $2").toLocaleLowerCase();
3851
4022
  const base = normalized.match(/[\p{L}\p{N}_-]+/gu) ?? [];
@@ -3861,24 +4032,123 @@ function tokenize(input2) {
3861
4032
  }
3862
4033
  return output2;
3863
4034
  }
4035
+ var retrievalStopWords = /* @__PURE__ */ new Set([
4036
+ "add",
4037
+ "audit",
4038
+ "change",
4039
+ "code",
4040
+ "create",
4041
+ "explain",
4042
+ "find",
4043
+ "fix",
4044
+ "implement",
4045
+ "inspect",
4046
+ "review",
4047
+ "show",
4048
+ "test",
4049
+ "the",
4050
+ "update",
4051
+ "where",
4052
+ "with",
4053
+ "\u4FEE\u6539",
4054
+ "\u4FEE\u590D",
4055
+ "\u5B9E\u73B0",
4056
+ "\u5BA1\u67E5",
4057
+ "\u67E5\u627E",
4058
+ "\u89E3\u91CA",
4059
+ "\u6D4B\u8BD5",
4060
+ "\u66F4\u65B0"
4061
+ ]);
4062
+ function hasSufficientQueryCoverage(matchedTerms, coverageTerms) {
4063
+ if (!coverageTerms.length) return matchedTerms.length > 0;
4064
+ const matched = new Set(matchedTerms);
4065
+ const covered = [...new Set(coverageTerms)].filter((term) => matched.has(term)).length;
4066
+ return covered >= Math.min(2, new Set(coverageTerms).size);
4067
+ }
4068
+ function expandQueryTerms(files, directTerms) {
4069
+ const expanded = /* @__PURE__ */ new Set();
4070
+ for (const file of files) {
4071
+ for (const definition of file.definitions) {
4072
+ const definitionTerms = [...new Set(tokenize(definition))];
4073
+ if (!definitionMatchesQuery(definitionTerms, directTerms)) continue;
4074
+ for (const term of definitionTerms) if (!directTerms.includes(term)) expanded.add(term);
4075
+ }
4076
+ }
4077
+ return [...expanded].sort((left, right) => left.localeCompare(right)).slice(0, 24);
4078
+ }
4079
+ function buildGraphBoosts(files, directTerms) {
4080
+ const anchors = /* @__PURE__ */ new Map();
4081
+ for (const file of files) {
4082
+ for (const definition of file.definitions) {
4083
+ if (!definitionMatchesQuery(tokenize(definition), directTerms)) continue;
4084
+ const values = anchors.get(file.absolutePath) ?? /* @__PURE__ */ new Set();
4085
+ values.add(definition.toLocaleLowerCase());
4086
+ anchors.set(file.absolutePath, values);
4087
+ }
4088
+ }
4089
+ if (!anchors.size) return /* @__PURE__ */ new Map();
4090
+ const boosts = /* @__PURE__ */ new Map();
4091
+ const addBoost = (path, score) => {
4092
+ boosts.set(path, { score: (boosts.get(path)?.score ?? 0) + score });
4093
+ };
4094
+ const anchorSymbols = new Set([...anchors.values()].flatMap((symbols) => [...symbols]));
4095
+ for (const file of files) {
4096
+ if (file.references.some((reference2) => anchorSymbols.has(reference2.toLocaleLowerCase()))) {
4097
+ addBoost(file.absolutePath, 1.5);
4098
+ }
4099
+ for (const specifier of file.imports) {
4100
+ for (const target of resolveImportTargets(file, specifier, files)) {
4101
+ if (anchors.has(target)) addBoost(file.absolutePath, 1.25);
4102
+ if (anchors.has(file.absolutePath)) addBoost(target, 0.75);
4103
+ }
4104
+ }
4105
+ }
4106
+ return boosts;
4107
+ }
4108
+ function definitionMatchesQuery(definitionTerms, queryTerms) {
4109
+ const definitions = new Set(definitionTerms);
4110
+ const shared = [...new Set(queryTerms)].filter((term) => definitions.has(term));
4111
+ const required = Math.min(2, Math.max(1, Math.min(definitions.size, queryTerms.length)));
4112
+ return shared.length >= required;
4113
+ }
4114
+ function resolveImportTargets(importer, specifier, files) {
4115
+ if (!specifier.startsWith(".")) return [];
4116
+ const base = posix.normalize(posix.join(posix.dirname(importer.path), specifier));
4117
+ const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
4118
+ const importedExtension = posix.extname(base);
4119
+ const extensionless = extensions.includes(importedExtension) ? base.slice(0, -importedExtension.length) : base;
4120
+ const candidates = /* @__PURE__ */ new Set([
4121
+ base,
4122
+ ...extensions.map((extension) => `${extensionless}${extension}`),
4123
+ ...extensions.map((extension) => posix.join(extensionless, `index${extension}`))
4124
+ ]);
4125
+ return files.filter((file) => file.root === importer.root && candidates.has(file.path)).map((file) => file.absolutePath);
4126
+ }
3864
4127
  function scoreChunk(chunk, terms, rawQuery, documentFrequency, documentCount, averageLength) {
3865
4128
  const frequencies = /* @__PURE__ */ new Map();
3866
4129
  for (const token of chunk.tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
3867
4130
  const k1 = 1.2;
3868
4131
  const b = 0.75;
3869
- let score = 0;
4132
+ let bm25 = 0;
4133
+ let path = 0;
4134
+ let symbol = 0;
4135
+ const matchedTerms = [];
3870
4136
  for (const term of terms) {
3871
4137
  const frequency = frequencies.get(term) ?? 0;
3872
4138
  if (!frequency) continue;
4139
+ matchedTerms.push(term);
3873
4140
  const df = documentFrequency.get(term) ?? 0;
3874
4141
  const idf = Math.log(1 + (documentCount - df + 0.5) / (df + 0.5));
3875
- score += idf * (frequency * (k1 + 1) / (frequency + k1 * (1 - b + b * chunk.tokens.length / averageLength)));
3876
- if (chunk.path.toLocaleLowerCase().includes(term)) score += 1.5;
3877
- if (chunk.symbol?.toLocaleLowerCase().includes(term)) score += 2.5;
4142
+ bm25 += idf * (frequency * (k1 + 1) / (frequency + k1 * (1 - b + b * chunk.tokens.length / averageLength)));
4143
+ if (chunk.path.toLocaleLowerCase().includes(term)) path += 1.5;
4144
+ if (chunk.symbol?.toLocaleLowerCase().includes(term)) symbol += 2.5;
3878
4145
  }
3879
4146
  const phrase = rawQuery.trim().toLocaleLowerCase();
3880
- if (phrase.length > 3 && chunk.content.toLocaleLowerCase().includes(phrase)) score += 3;
3881
- return score;
4147
+ const phraseScore = phrase.length > 3 && chunk.content.toLocaleLowerCase().includes(phrase) ? 3 : 0;
4148
+ return { bm25, path, symbol, phrase: phraseScore, matchedTerms };
4149
+ }
4150
+ function roundScore(value) {
4151
+ return Number(value.toFixed(6));
3882
4152
  }
3883
4153
 
3884
4154
  // src/context/context-engine.ts
@@ -3989,7 +4259,10 @@ function formatContextHits(hits, roots) {
3989
4259
  return hits.map((hit) => {
3990
4260
  const path = workspaceAliasPath(hit.path, roots);
3991
4261
  const symbol = hit.symbol ? ` ${hit.symbol}` : "";
3992
- return `[${hit.source} ${hit.score.toFixed(3)}]${symbol} ${path}:${hit.startLine}-${hit.endLine}`;
4262
+ const score = hit.provenance?.score;
4263
+ const breakdown = score ? ` bm25=${score.bm25.toFixed(2)} path=${score.path.toFixed(2)} symbol=${score.symbol.toFixed(2)} graph=${score.graph.toFixed(2)}` : "";
4264
+ const hash4 = hit.provenance?.contentHash.slice(0, 12);
4265
+ return `[${hit.source} ${hit.score.toFixed(3)}${breakdown}${hash4 ? ` hash=${hash4}` : ""}]${symbol} ${path}:${hit.startLine}-${hit.endLine}`;
3993
4266
  }).join("\n");
3994
4267
  }
3995
4268
 
@@ -9719,7 +9992,7 @@ async function evaluateReuseGate(input2) {
9719
9992
  candidates.push({
9720
9993
  path: hit.path,
9721
9994
  ...hit.symbol ? { symbol: hit.symbol.slice(0, 160) } : {},
9722
- score: roundScore(hit.score),
9995
+ score: roundScore2(hit.score),
9723
9996
  read
9724
9997
  });
9725
9998
  }
@@ -9827,7 +10100,7 @@ function unresolvedReceipt(input2, queryHash, targetPaths, trigger, detail) {
9827
10100
  function hash3(value) {
9828
10101
  return createHash13("sha256").update(value).digest("hex");
9829
10102
  }
9830
- function roundScore(value) {
10103
+ function roundScore2(value) {
9831
10104
  return Number.isFinite(value) ? Math.round(value * 1e3) / 1e3 : 0;
9832
10105
  }
9833
10106