dsh-code-index 0.7.0 → 0.9.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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/buildIndex.ts
2
- import { readFile as readFile3, stat as stat2 } from "fs/promises";
2
+ import { readFile as readFile4, stat as stat2 } from "fs/promises";
3
3
  import path4 from "path";
4
4
 
5
5
  // src/extract.ts
@@ -800,9 +800,9 @@ function rustBindings(node) {
800
800
  return name2 ? [{ imported: name2, local: name2, kind: "named" }] : [];
801
801
  }
802
802
  case "use_as_clause": {
803
- const path9 = node.childForFieldName("path");
803
+ const path11 = node.childForFieldName("path");
804
804
  const alias = node.childForFieldName("alias")?.text.trim();
805
- const imported = path9 ? rustLeafName(path9) : null;
805
+ const imported = path11 ? rustLeafName(path11) : null;
806
806
  return imported && alias ? [{ imported, local: alias, kind: "named" }] : [];
807
807
  }
808
808
  case "scoped_use_list": {
@@ -823,8 +823,8 @@ function rustLeafName(node) {
823
823
  return node.childForFieldName("name")?.text.trim() ?? null;
824
824
  }
825
825
  function javaImport(node, line) {
826
- const path9 = node.namedChildren.find((child) => child?.type === "scoped_identifier");
827
- const specifier = dottedToPath(path9?.text);
826
+ const path11 = node.namedChildren.find((child) => child?.type === "scoped_identifier");
827
+ const specifier = dottedToPath(path11?.text);
828
828
  if (!specifier) return [];
829
829
  const wildcard = node.namedChildren.some((child) => child?.type === "asterisk");
830
830
  if (wildcard) {
@@ -832,7 +832,7 @@ function javaImport(node, line) {
832
832
  { specifier, line, kind: "import", bindings: [{ imported: "*", local: "*", kind: "glob" }] }
833
833
  ];
834
834
  }
835
- const imported = path9?.childForFieldName("name")?.text.trim() ?? lastSlashSegment(specifier);
835
+ const imported = path11?.childForFieldName("name")?.text.trim() ?? lastSlashSegment(specifier);
836
836
  const isStatic = /^import\s+static\b/.test(node.text);
837
837
  return [
838
838
  {
@@ -1009,8 +1009,10 @@ async function parseFileToSymbols(filePath, repoRoot, code) {
1009
1009
  }
1010
1010
 
1011
1011
  // src/scan.ts
1012
- import { readdir, stat } from "fs/promises";
1012
+ import { readFile as readFile2, readdir, stat } from "fs/promises";
1013
+ import { readFileSync, statSync } from "fs";
1013
1014
  import path2 from "path";
1015
+ import ignore from "ignore";
1014
1016
  var DEFAULT_EXCLUDED_DIRS = [
1015
1017
  "node_modules",
1016
1018
  ".git",
@@ -1054,12 +1056,51 @@ var SUPPORTED_EXTS = /* @__PURE__ */ new Set([
1054
1056
  ".inl",
1055
1057
  ".c"
1056
1058
  ]);
1059
+ function isGitIgnoredPath(root, relativePath, cache = /* @__PURE__ */ new Map()) {
1060
+ const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
1061
+ if (!rel) return false;
1062
+ const parentParts = rel.split("/").slice(0, -1);
1063
+ for (let depth = 0; depth <= parentParts.length; depth += 1) {
1064
+ const base = parentParts.slice(0, depth).join("/");
1065
+ const ignoreFile = path2.join(root, base, ".gitignore");
1066
+ let fileStat;
1067
+ try {
1068
+ fileStat = statSync(ignoreFile);
1069
+ } catch {
1070
+ continue;
1071
+ }
1072
+ const previous = cache.get(ignoreFile);
1073
+ let matcher = previous?.matcher;
1074
+ if (!previous || previous.mtimeMs !== fileStat.mtimeMs || previous.size !== fileStat.size) {
1075
+ try {
1076
+ matcher = ignore().add(readFileSync(ignoreFile, "utf8"));
1077
+ cache.set(ignoreFile, { mtimeMs: fileStat.mtimeMs, size: fileStat.size, matcher });
1078
+ } catch {
1079
+ cache.delete(ignoreFile);
1080
+ continue;
1081
+ }
1082
+ }
1083
+ const scoped = base ? path2.posix.relative(base, rel) : rel;
1084
+ if (matcher?.ignores(scoped)) return true;
1085
+ }
1086
+ return false;
1087
+ }
1057
1088
  async function scanRepo(root, options = {}) {
1058
1089
  const excluded = /* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs ?? []]);
1059
1090
  const results = [];
1060
- const queue = [[root, ""]];
1091
+ const queue = [[root, "", []]];
1061
1092
  while (queue.length) {
1062
- const [absDir, relDir] = queue.pop();
1093
+ const [absDir, relDir, inherited] = queue.pop();
1094
+ let layers = inherited;
1095
+ try {
1096
+ const rules = await readFile2(path2.join(absDir, ".gitignore"), "utf8");
1097
+ layers = [...inherited, { base: relDir, matcher: ignore().add(rules) }];
1098
+ } catch {
1099
+ }
1100
+ const ignoredByGit = (relativePath) => layers.some(({ base, matcher }) => {
1101
+ const scoped = base ? path2.posix.relative(base, relativePath) : relativePath;
1102
+ return scoped !== ".." && !scoped.startsWith("../") && matcher.ignores(scoped);
1103
+ });
1063
1104
  let entries;
1064
1105
  try {
1065
1106
  entries = await readdir(absDir, { withFileTypes: true });
@@ -1071,8 +1112,8 @@ async function scanRepo(root, options = {}) {
1071
1112
  const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1072
1113
  if (entry.isDirectory()) {
1073
1114
  if (excluded.has(entry.name)) continue;
1074
- queue.push([abs, rel]);
1075
- } else if (entry.isFile() && SUPPORTED_EXTS.has(path2.extname(entry.name).toLowerCase())) {
1115
+ if (!ignoredByGit(rel)) queue.push([abs, rel, layers]);
1116
+ } else if (entry.isFile() && !ignoredByGit(rel) && SUPPORTED_EXTS.has(path2.extname(entry.name).toLowerCase())) {
1076
1117
  try {
1077
1118
  const st = await stat(abs);
1078
1119
  results.push({ abs, rel, mtimeMs: st.mtimeMs });
@@ -1086,7 +1127,7 @@ async function scanRepo(root, options = {}) {
1086
1127
  }
1087
1128
 
1088
1129
  // src/store.ts
1089
- import { mkdir, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
1130
+ import { mkdir, readFile as readFile3, rename, rm, writeFile } from "fs/promises";
1090
1131
  import path3 from "path";
1091
1132
  import { createHash, randomUUID } from "crypto";
1092
1133
 
@@ -1114,7 +1155,7 @@ function legacyCachePath(root, cacheDir) {
1114
1155
  }
1115
1156
  async function loadIndex(cachePath) {
1116
1157
  try {
1117
- const raw = await readFile2(cachePath, "utf8");
1158
+ const raw = await readFile3(cachePath, "utf8");
1118
1159
  const parsed = JSON.parse(raw);
1119
1160
  if (!parsed || typeof parsed.root !== "string" || !Array.isArray(parsed.files)) return null;
1120
1161
  if (parsed.schemaVersion !== REPO_INDEX_SCHEMA_VERSION) return null;
@@ -1143,7 +1184,7 @@ async function saveIndex(cachePath, index) {
1143
1184
  }
1144
1185
 
1145
1186
  // src/buildIndex.ts
1146
- async function buildIndex(root, options = {}, previous = null) {
1187
+ async function buildIndex(root, options = {}, previous = null, forcePaths = /* @__PURE__ */ new Set()) {
1147
1188
  const scanned = await scanRepo(root, options);
1148
1189
  const prevByPath = new Map((previous?.files ?? []).map((f) => [f.path, f]));
1149
1190
  const prevMtime = new Map((previous?.files ?? []).map((f) => [f.path, f.mtimeMs]));
@@ -1155,7 +1196,7 @@ async function buildIndex(root, options = {}, previous = null) {
1155
1196
  batch.map(async (f) => {
1156
1197
  const lang = languageForFile(f.abs);
1157
1198
  if (!lang) return null;
1158
- if (prevMtime.get(f.rel) === f.mtimeMs) {
1199
+ if (prevMtime.get(f.rel) === f.mtimeMs && !forcePaths.has(f.rel) && !forcePaths.has("*")) {
1159
1200
  const cached = prevByPath.get(f.rel);
1160
1201
  return {
1161
1202
  ...cached,
@@ -1165,7 +1206,7 @@ async function buildIndex(root, options = {}, previous = null) {
1165
1206
  }
1166
1207
  let code;
1167
1208
  try {
1168
- code = await readFile3(f.abs, "utf8");
1209
+ code = await readFile4(f.abs, "utf8");
1169
1210
  } catch {
1170
1211
  return null;
1171
1212
  }
@@ -1193,7 +1234,7 @@ async function buildIndex(root, options = {}, previous = null) {
1193
1234
  excludedDirs: [...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs ?? []]
1194
1235
  };
1195
1236
  }
1196
- async function buildIndexWithCache(root, options = {}, cacheDir) {
1237
+ async function buildIndexWithCache(root, options = {}, cacheDir, forcePaths = /* @__PURE__ */ new Set()) {
1197
1238
  const cachePath = defaultCachePath(root, cacheDir);
1198
1239
  let prev = await loadIndex(cachePath);
1199
1240
  let loadedLegacy = false;
@@ -1205,7 +1246,7 @@ async function buildIndexWithCache(root, options = {}, cacheDir) {
1205
1246
  }
1206
1247
  }
1207
1248
  const reusable = prev && cacheKeyForRoot(prev.root) === cacheKeyForRoot(root) ? prev : null;
1208
- const fresh = await buildIndex(root, options, reusable);
1249
+ const fresh = await buildIndex(root, options, reusable, forcePaths);
1209
1250
  if (prev && indexesEqual(prev, fresh)) {
1210
1251
  if (loadedLegacy) await saveIndex(cachePath, fresh);
1211
1252
  return loadedLegacy ? fresh : prev;
@@ -1794,13 +1835,13 @@ function findOrphanModules(index) {
1794
1835
  index.files.filter((file) => file.symbols.length > 0).map((file) => file.path)
1795
1836
  );
1796
1837
  return nodes.filter(
1797
- (path9) => withSymbols.has(path9) && (incoming.get(path9) ?? 0) === 0 && (edges.get(path9)?.size ?? 0) === 0 && !ENTRY_RE.test(path9) && !TEST_RE.test(path9)
1838
+ (path11) => withSymbols.has(path11) && (incoming.get(path11) ?? 0) === 0 && (edges.get(path11)?.size ?? 0) === 0 && !ENTRY_RE.test(path11) && !TEST_RE.test(path11)
1798
1839
  );
1799
1840
  }
1800
1841
 
1801
1842
  // src/git-diff.ts
1802
1843
  import { execFile } from "child_process";
1803
- import { readFile as readFile4 } from "fs/promises";
1844
+ import { readFile as readFile5 } from "fs/promises";
1804
1845
  import path6 from "path";
1805
1846
  var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
1806
1847
  var HEADER_PATH_PREFIX_RE = /^[ab]\//;
@@ -1999,6 +2040,12 @@ function runGit(args, cwd) {
1999
2040
  });
2000
2041
  });
2001
2042
  }
2043
+ async function resolveGitRef(root, ref) {
2044
+ requireSafeRef(ref, "baseRef");
2045
+ const commit = (await runGit(["rev-parse", "--verify", `${ref}^{commit}`], root)).trim();
2046
+ if (!/^[a-f0-9]{40,64}$/i.test(commit)) throw new Error("invalid baseline commit");
2047
+ return commit;
2048
+ }
2002
2049
  async function readWorkingTreeDiff(root, baseRef) {
2003
2050
  requireSafeRef(baseRef, "baseRef");
2004
2051
  const tracked = await runGit(
@@ -2013,7 +2060,7 @@ async function readWorkingTreeDiff(root, baseRef) {
2013
2060
  const normalized = rawPath.replace(/\\/g, "/");
2014
2061
  if (normalized === ".dsh-code-index" || normalized.startsWith(".dsh-code-index/")) continue;
2015
2062
  try {
2016
- const content = await readFile4(path6.resolve(root, normalized), "utf8");
2063
+ const content = await readFile5(path6.resolve(root, normalized), "utf8");
2017
2064
  const lines = content.length === 0 ? 0 : content.split(/\r?\n/).length - (content.endsWith("\n") ? 1 : 0);
2018
2065
  untrackedFiles.push({ path: normalized, content, lines });
2019
2066
  } catch {
@@ -2149,9 +2196,15 @@ async function buildChangeContext(index, input, options = {}) {
2149
2196
  `command root ${path7.resolve(commandRoot)} differs from indexed root ${index.root}; using the cached index`
2150
2197
  );
2151
2198
  }
2152
- const addChanged = (symbol, change, side) => {
2199
+ const addChanged = (symbol, change, side, focusLine) => {
2153
2200
  const key = `${side}:${symbol.id}`;
2154
- if (!changedByKey.has(key)) changedByKey.set(key, { symbol, change, side, resolution: "exact" });
2201
+ if (!changedByKey.has(key)) changedByKey.set(key, {
2202
+ symbol,
2203
+ change,
2204
+ side,
2205
+ resolution: "exact",
2206
+ ...focusLine === void 0 ? {} : { focusLine }
2207
+ });
2155
2208
  };
2156
2209
  const addUnmapped = (file, side, ranges, reason) => {
2157
2210
  if (ranges.length > 0) unmapped.push({ file, side, ranges, reason });
@@ -2181,11 +2234,17 @@ async function buildChangeContext(index, input, options = {}) {
2181
2234
  } else {
2182
2235
  let text;
2183
2236
  if (input.kind === "git") {
2184
- baseRef = input.baseRef;
2185
- text = await readWorkingTreeDiff(input.root, input.baseRef);
2237
+ baseRef = await resolveGitRef(input.root, input.baseRef);
2238
+ text = await readWorkingTreeDiff(input.root, baseRef);
2186
2239
  } else {
2187
2240
  text = input.text;
2188
- baseRef = input.baseRef;
2241
+ if (input.baseRef !== void 0) {
2242
+ try {
2243
+ baseRef = await resolveGitRef(input.root, input.baseRef);
2244
+ } catch {
2245
+ warnings.push(`baseline unavailable: ${input.baseRef}`);
2246
+ }
2247
+ }
2189
2248
  }
2190
2249
  const changes = parseUnifiedDiff(text).map((change) => ({
2191
2250
  ...change,
@@ -2339,6 +2398,13 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2339
2398
  const { oldPath, newPath, hunks } = change;
2340
2399
  if (newPath) changedFiles.add(newPath);
2341
2400
  if (oldPath) changedFiles.add(oldPath);
2401
+ const identity = (symbol) => JSON.stringify([
2402
+ symbol.kind,
2403
+ symbol.name,
2404
+ symbol.scope.map((owner) => [owner.kind, owner.name])
2405
+ ]);
2406
+ const oldSymbols = oldPath === null ? [] : await readBaseSymbols(oldPath);
2407
+ const count = (symbols, symbol) => symbols.filter((candidate) => identity(candidate) === identity(symbol)).length;
2342
2408
  const currentSymbols = newPath !== null && change.status !== "deleted" ? symbolsByPath.get(newPath) : void 0;
2343
2409
  if (newPath !== null && change.status !== "deleted") {
2344
2410
  const currentRanges = hunks.filter((hunk) => hunk.newLines > 0).map((hunk) => ({
@@ -2348,7 +2414,14 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2348
2414
  if (currentRanges.length > 0 && currentSymbols) {
2349
2415
  for (const range of currentRanges) {
2350
2416
  for (const symbol of currentSymbols) {
2351
- if (intersects(symbol, range)) addChanged(symbol, change.status, "current");
2417
+ if (!intersects(symbol, range)) continue;
2418
+ let classification = "unclassified";
2419
+ if (change.status === "added") classification = "added";
2420
+ else if (oldSymbols !== null && count(currentSymbols, symbol) === 1) {
2421
+ const matches = count(oldSymbols, symbol);
2422
+ classification = matches === 0 ? "added" : matches === 1 ? "modified" : "unclassified";
2423
+ }
2424
+ addChanged(symbol, classification, "current", Math.max(symbol.line, range.startLine));
2352
2425
  }
2353
2426
  }
2354
2427
  const missed = currentRanges.filter(
@@ -2361,29 +2434,36 @@ async function mapDiffChanges(changes, symbolsByPath, addChanged, addUnmapped, c
2361
2434
  if (change.status === "renamed" && hunks.length === 0) {
2362
2435
  for (const symbol of currentSymbols ?? []) addChanged(symbol, "renamed", "current");
2363
2436
  if (oldPath !== null) {
2364
- const oldSymbols = await readBaseSymbols(oldPath);
2365
- if (oldSymbols === null) {
2437
+ const oldSymbols2 = await readBaseSymbols(oldPath);
2438
+ if (oldSymbols2 === null) {
2366
2439
  warnings.push(`rename baseline unavailable: ${oldPath}`);
2367
2440
  } else {
2368
- for (const symbol of oldSymbols) addChanged(symbol, "renamed", "base");
2441
+ for (const symbol of oldSymbols2) addChanged(symbol, "renamed", "base");
2369
2442
  }
2370
2443
  }
2371
2444
  }
2372
2445
  }
2373
- const deletionHunks = hunks.filter((hunk) => hunk.newLines === 0);
2446
+ const deletionHunks = hunks.filter((hunk) => hunk.oldLines > 0);
2374
2447
  if (deletionHunks.length > 0 && oldPath !== null) {
2375
2448
  const oldRanges = deletionHunks.map((hunk) => ({
2376
2449
  startLine: hunk.oldStart,
2377
2450
  endLine: hunk.oldStart + Math.max(hunk.oldLines, 1) - 1
2378
2451
  }));
2379
- const oldSymbols = await readBaseSymbols(oldPath);
2380
2452
  if (oldSymbols === null) {
2381
2453
  addUnmapped(oldPath, "base", oldRanges, "base-content-unavailable");
2382
2454
  } else {
2383
- const baseChange = change.status === "deleted" ? "deleted" : "modified";
2384
2455
  for (const range of oldRanges) {
2385
2456
  for (const symbol of oldSymbols) {
2386
- if (intersects(symbol, range)) addChanged(symbol, baseChange, "base");
2457
+ if (!intersects(symbol, range)) continue;
2458
+ const matches = count(currentSymbols ?? [], symbol);
2459
+ if (change.status === "deleted" || matches === 0 && count(oldSymbols, symbol) === 1) {
2460
+ addChanged(symbol, "deleted", "base", Math.max(symbol.line, range.startLine));
2461
+ } else if (matches > 1 || count(oldSymbols, symbol) > 1) {
2462
+ addChanged(symbol, "unclassified", "base");
2463
+ } else if (hunks.some((hunk) => hunk.newLines === 0 && intersects(symbol, {
2464
+ startLine: hunk.oldStart,
2465
+ endLine: hunk.oldStart + hunk.oldLines - 1
2466
+ }))) addChanged(symbol, "modified", "base");
2387
2467
  }
2388
2468
  }
2389
2469
  const missed = oldRanges.filter(
@@ -2541,6 +2621,15 @@ var TEST_RANK = {
2541
2621
  "references-symbol-name": 3,
2542
2622
  "path-convention": 4
2543
2623
  };
2624
+ function collectSeedTests(index, seeds) {
2625
+ return collectAffectedTests(
2626
+ index,
2627
+ buildModuleGraph(index),
2628
+ new Set(seeds.map((seed) => seed.file)),
2629
+ seeds.map((symbol) => ({ symbol, side: "current", change: "unclassified", resolution: "exact" })),
2630
+ []
2631
+ );
2632
+ }
2544
2633
  function collectAffectedTests(index, moduleGraph, changedFiles, changedSymbols, impactRows) {
2545
2634
  const changedNames = new Set(changedSymbols.map((changed) => changed.symbol.name));
2546
2635
  const impactFileDepth = /* @__PURE__ */ new Map();
@@ -2714,6 +2803,293 @@ function renderChangeContext(result, maxChars = 12e3) {
2714
2803
  return out;
2715
2804
  }
2716
2805
 
2806
+ // src/source-excerpts.ts
2807
+ import { readFile as readFile6, realpath, stat as stat3 } from "fs/promises";
2808
+ import path8 from "path";
2809
+ function sourceReader(index) {
2810
+ const cache = /* @__PURE__ */ new Map();
2811
+ return async (file, side, ref) => {
2812
+ const key = `${side}:${ref ?? ""}:${file}`;
2813
+ if (!cache.has(key)) cache.set(key, (async () => {
2814
+ if (side === "base") {
2815
+ if (!ref) return null;
2816
+ const code = await readGitFileAtRef(index.root, ref, file);
2817
+ return code === null ? null : code.split(/\r?\n/);
2818
+ }
2819
+ const indexed = index.files.find((entry) => entry.path === file);
2820
+ if (!indexed) return null;
2821
+ try {
2822
+ const root = await realpath(index.root);
2823
+ const target = await realpath(path8.join(root, file));
2824
+ const relative = path8.relative(root, target);
2825
+ if (relative.startsWith("..") || path8.isAbsolute(relative)) return null;
2826
+ const before = await stat3(target);
2827
+ if (before.size > 1e6 || before.mtimeMs !== indexed.mtimeMs) return null;
2828
+ const code = await readFile6(target, "utf8");
2829
+ const after = await stat3(target);
2830
+ if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) return null;
2831
+ return code.split(/\r?\n/);
2832
+ } catch {
2833
+ return null;
2834
+ }
2835
+ })());
2836
+ return cache.get(key);
2837
+ };
2838
+ }
2839
+ function excerptVariants(lines, symbol, side = "current", ref, focusLine = symbol.line) {
2840
+ const base = { file: symbol.file, side, ...ref ? { ref } : {} };
2841
+ const signature = {
2842
+ ...base,
2843
+ startLine: symbol.line,
2844
+ endLine: symbol.line,
2845
+ mode: "signature",
2846
+ signature: symbol.signature
2847
+ };
2848
+ if (!lines || symbol.line < 1 || symbol.endLine > lines.length) return [signature];
2849
+ const ranges = [
2850
+ { start: symbol.line, end: symbol.endLine, mode: "complete" },
2851
+ { start: Math.max(symbol.line, focusLine - 3), end: Math.min(symbol.endLine, focusLine + 5), mode: "window" },
2852
+ { start: symbol.line, end: Math.min(symbol.endLine, symbol.line + 2), mode: "window" }
2853
+ ];
2854
+ const seen = /* @__PURE__ */ new Set();
2855
+ const variants = [];
2856
+ for (const range of ranges) {
2857
+ const key = `${range.start}:${range.end}`;
2858
+ if (seen.has(key) || range.end < range.start) continue;
2859
+ seen.add(key);
2860
+ const code = lines.slice(range.start - 1, range.end).join("\n");
2861
+ if (code.length > 1600 || range.end - range.start >= 80) continue;
2862
+ variants.push({
2863
+ ...base,
2864
+ startLine: range.start,
2865
+ endLine: range.end,
2866
+ mode: range.mode,
2867
+ code,
2868
+ ...range.mode === "window" ? { signature: symbol.signature } : {}
2869
+ });
2870
+ }
2871
+ return [...variants, signature];
2872
+ }
2873
+ function subtractExcerpt(excerpt, selected) {
2874
+ if (excerpt.code === void 0) return [excerpt];
2875
+ const covered = selected.filter((item) => item.code !== void 0 && item.file === excerpt.file && item.side === excerpt.side && item.ref === excerpt.ref);
2876
+ const lines = excerpt.code.split("\n");
2877
+ const fragments = [];
2878
+ let start = null;
2879
+ for (let line = excerpt.startLine; line <= excerpt.endLine + 1; line++) {
2880
+ const include = line <= excerpt.endLine && !covered.some((item) => line >= item.startLine && line <= item.endLine);
2881
+ if (include && start === null) start = line;
2882
+ if (!include && start !== null) {
2883
+ fragments.push({
2884
+ ...excerpt,
2885
+ startLine: start,
2886
+ endLine: line - 1,
2887
+ mode: start === excerpt.startLine && line - 1 === excerpt.endLine ? excerpt.mode : "window",
2888
+ code: lines.slice(start - excerpt.startLine, line - excerpt.startLine).join("\n")
2889
+ });
2890
+ start = null;
2891
+ }
2892
+ }
2893
+ return fragments;
2894
+ }
2895
+
2896
+ // src/context-pack.ts
2897
+ function renderContextPack(pack) {
2898
+ const rows = [`Task context \u2014 ${pack.route}`, `Repo/worktree: ${pack.root}`, `Task: ${pack.task}`];
2899
+ for (const item of pack.items) {
2900
+ const location = item.file ? ` ${item.file}:${item.startLine ?? 1}-${item.endLine ?? item.startLine ?? 1}` : "";
2901
+ rows.push(`
2902
+ ${item.kind}: ${item.name ?? item.text ?? ""}${location}${item.side ? ` [${item.side}${item.ref ? ` @${item.ref}` : ""}]` : ""}`);
2903
+ rows.push(`Reason: ${item.reason}`);
2904
+ if (item.change) rows.push(`Change: ${item.change}`);
2905
+ if (item.resolution) rows.push(`Relationship provenance: ${item.resolution}${item.provenance ? `; ${item.provenance}` : ""}`);
2906
+ if (item.code !== void 0) {
2907
+ if (item.signature) rows.push(`Declaration: ${item.signature}`);
2908
+ rows.push(`Source (${item.mode}):
2909
+ ${item.code}`);
2910
+ } else if (item.mode === "signature") rows.push(`Signature only: ${item.signature || "(source unavailable within budget)"}`);
2911
+ }
2912
+ if (pack.gaps.length) rows.push(`
2913
+ Gaps: ${pack.gaps.join("; ")}`);
2914
+ rows.push(`Budget: ${pack.budget.usedChars} / ${pack.budget.budgetChars} chars${pack.budget.truncated ? " (trimmed low-priority context)" : ""}`);
2915
+ return rows.join("\n");
2916
+ }
2917
+ function account(pack) {
2918
+ for (let i = 0; i < 8; i++) {
2919
+ const textChars = renderContextPack(pack).length;
2920
+ const packChars = JSON.stringify(pack).length;
2921
+ if (pack.budget.usedChars === textChars && pack.budget.packChars === packChars) break;
2922
+ pack.budget.usedChars = textChars;
2923
+ pack.budget.packChars = packChars;
2924
+ }
2925
+ }
2926
+ async function selectContextPack(index, context, budgetChars, maxFiles = 12) {
2927
+ const read = sourceReader(index);
2928
+ const graph = buildReferenceGraph(index);
2929
+ const candidates = [];
2930
+ const seen = /* @__PURE__ */ new Set();
2931
+ const gaps = [];
2932
+ const unavailable = /* @__PURE__ */ new Set();
2933
+ const source = async (symbol, kind, reason, extra = {}, focus) => {
2934
+ const side = extra.side ?? "current";
2935
+ const key = `${side}:${extra.ref ?? ""}:${kind}:${symbol.file}:${symbol.line}:${symbol.name}`;
2936
+ if (seen.has(key)) return;
2937
+ seen.add(key);
2938
+ const lines = await read(symbol.file, side, extra.ref);
2939
+ if (!lines) unavailable.add(`${side}:${symbol.file}`);
2940
+ candidates.push(excerptVariants(lines, symbol, side, extra.ref, focus).map((excerpt) => ({
2941
+ ...excerpt,
2942
+ ...symbol.id === "" && excerpt.mode === "complete" ? { mode: "window" } : {},
2943
+ kind,
2944
+ name: symbol.name,
2945
+ reason,
2946
+ ...extra
2947
+ })));
2948
+ };
2949
+ for (const primary of context.primarySymbols) {
2950
+ const changed = context.changeContext?.changed.find((row) => row.side === "current" && row.symbol.id === primary.symbol.id);
2951
+ await source(primary.symbol, "primary", primary.reason, changed ? { change: changed.change } : {}, changed?.focusLine);
2952
+ }
2953
+ for (const changed of context.changeContext?.changed ?? []) {
2954
+ if (changed.side === "current" && context.primarySymbols.some((seed) => seed.symbol.id === changed.symbol.id)) continue;
2955
+ await source(changed.symbol, "change", "declaration intersects Git change", {
2956
+ side: changed.side,
2957
+ ...changed.side === "base" ? { ref: context.changeContext.baseRef } : {},
2958
+ change: changed.change
2959
+ }, changed.focusLine);
2960
+ }
2961
+ for (const test of context.testEvidence) {
2962
+ const file = index.files.find((row) => row.path === test.file);
2963
+ const seedNames = new Set(context.primarySymbols.map((seed) => seed.symbol.name));
2964
+ const call = file.calls?.find((call2) => seedNames.has(call2.name));
2965
+ const lines = await read(file.path, "current");
2966
+ const symbol = file.symbols.find((symbol2) => call && symbol2.line <= call.line && symbol2.endLine >= call.line);
2967
+ await source(
2968
+ symbol ?? {
2969
+ id: "",
2970
+ name: test.file,
2971
+ file: test.file,
2972
+ line: Math.max(1, (call?.line ?? 1) - 3),
2973
+ endLine: Math.min(lines?.length ?? 1, (call?.line ?? 1) + 5),
2974
+ signature: "",
2975
+ scope: [],
2976
+ ordinal: 1,
2977
+ kind: "function",
2978
+ exported: false
2979
+ },
2980
+ "test",
2981
+ test.reason === "imports-changed-file" ? "test imports primary seed file" : test.reason,
2982
+ { resolution: test.resolution, provenance: test.reason },
2983
+ call?.line
2984
+ );
2985
+ }
2986
+ for (const primary of context.primarySymbols.slice(0, 4)) {
2987
+ for (const direction of ["caller", "callee"]) {
2988
+ const edges = direction === "caller" ? graph.incoming.get(primary.symbol.id) : graph.outgoing.get(primary.symbol.id);
2989
+ const strongEdges = (edges ?? []).filter((edge) => edge.resolution !== "name-only").sort((a, b) => a.resolution.localeCompare(b.resolution) || a.callSite.file.localeCompare(b.callSite.file) || a.callSite.line - b.callSite.line);
2990
+ for (const edge of strongEdges.slice(0, 4)) {
2991
+ const symbol = graph.symbolsById.get(direction === "caller" ? edge.sourceId ?? "" : edge.targetId);
2992
+ if (symbol && !context.primarySymbols.some((seed) => seed.symbol.id === symbol.id)) {
2993
+ await source(
2994
+ symbol,
2995
+ direction,
2996
+ `direct ${direction} of ${primary.symbol.name}`,
2997
+ { resolution: edge.resolution, provenance: edge.provenance },
2998
+ direction === "caller" ? edge.callSite.line : symbol.line
2999
+ );
3000
+ }
3001
+ }
3002
+ }
3003
+ const file = index.files.find((file2) => file2.path === primary.symbol.file);
3004
+ const lines = await read(file.path, "current");
3005
+ const body = lines?.slice(primary.symbol.line - 1, primary.symbol.endLine).join("\n") ?? primary.symbol.signature;
3006
+ const relevantImports = (file.importDetails ?? []).filter((imported) => imported.bindings.some((binding) => {
3007
+ const escaped = binding.local.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3008
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}(?=$|[^A-Za-z0-9_$])`).test(body);
3009
+ }));
3010
+ for (const imported of relevantImports.slice(0, 3)) {
3011
+ const offset = lines?.slice(imported.line - 1, imported.line + 11).findIndex((line) => line.includes(imported.specifier)) ?? -1;
3012
+ const endLine = offset < 0 ? imported.line : imported.line + offset;
3013
+ await source(
3014
+ {
3015
+ ...primary.symbol,
3016
+ ...offset < 0 ? { id: "" } : {},
3017
+ name: imported.specifier,
3018
+ line: imported.line,
3019
+ endLine,
3020
+ signature: ""
3021
+ },
3022
+ "import",
3023
+ `import binding referenced in primary source ${primary.symbol.file}`,
3024
+ { resolution: "import-scoped", provenance: "import statement" }
3025
+ );
3026
+ }
3027
+ }
3028
+ for (const relation of context.relationships) candidates.push([{
3029
+ kind: "relationship",
3030
+ text: relation.text,
3031
+ reason: "bounded primary graph neighborhood",
3032
+ resolution: relation.resolution,
3033
+ provenance: relation.provenance
3034
+ }]);
3035
+ if (!context.primarySymbols.length) for (const file of context.relevantFiles.slice(0, 4)) {
3036
+ candidates.push([{ kind: "file", file: file.path, reason: file.reason }]);
3037
+ }
3038
+ if (!context.primarySymbols.length) gaps.push("no strong primary symbol; specify a symbol, path, or failing behavior");
3039
+ if (!context.testEvidence.length) gaps.push("no related indexed test found");
3040
+ gaps.push(...context.warnings);
3041
+ const pack = {
3042
+ version: 1,
3043
+ root: index.root,
3044
+ task: context.task.slice(0, 120),
3045
+ route: context.route.kind,
3046
+ items: [],
3047
+ gaps: ["budget omitted some evidence"],
3048
+ budget: { budgetChars, usedChars: 0, packChars: 0, truncated: context.task.length > 120 }
3049
+ };
3050
+ const fits = () => {
3051
+ account(pack);
3052
+ return Math.max(pack.budget.usedChars, pack.budget.packChars) <= budgetChars;
3053
+ };
3054
+ if (!fits()) {
3055
+ pack.task = "";
3056
+ pack.gaps = [];
3057
+ fits();
3058
+ }
3059
+ if (!fits()) throw new Error("budget too small for repository identity");
3060
+ for (const variants of candidates) {
3061
+ let added = false;
3062
+ for (const candidate of variants) {
3063
+ const fragments = candidate.code !== void 0 ? subtractExcerpt(candidate, pack.items.filter((item) => item.code !== void 0)).map((fragment) => ({ ...candidate, ...fragment })) : [candidate];
3064
+ if (!fragments.length) continue;
3065
+ pack.items.push(...fragments);
3066
+ const fileCount = new Set(pack.items.flatMap((item) => item.file ? [item.file] : [])).size;
3067
+ if (fits() && fileCount <= maxFiles) {
3068
+ added = true;
3069
+ if (candidate.mode === "signature") pack.budget.truncated = true;
3070
+ break;
3071
+ }
3072
+ pack.items.splice(pack.items.length - fragments.length);
3073
+ }
3074
+ if (!added) pack.budget.truncated = true;
3075
+ }
3076
+ if (!pack.budget.truncated) pack.gaps = [];
3077
+ for (const item of pack.items) if (item.file && unavailable.has(`${item.side}:${item.file}`)) {
3078
+ gaps.push(`source unavailable or changed after indexing: ${item.file} (${item.side})`);
3079
+ }
3080
+ for (const gap of [...new Set(gaps)].slice(0, 4)) {
3081
+ pack.gaps.push(gap.slice(0, 140));
3082
+ if (!fits()) pack.gaps.pop();
3083
+ }
3084
+ account(pack);
3085
+ while (Math.max(pack.budget.usedChars, pack.budget.packChars) > budgetChars && pack.items.length) {
3086
+ pack.items.pop();
3087
+ pack.budget.truncated = true;
3088
+ account(pack);
3089
+ }
3090
+ return pack;
3091
+ }
3092
+
2717
3093
  // src/context.ts
2718
3094
  var TEST_PATH_RE2 = /(^|\/)(tests?|__tests__)(\/|$)|\.(?:spec|test)\.[cm]?[jt]sx?$|(^|\/)(?:test_[^/]+\.py|[^/]+_test\.(?:py|go))$/i;
2719
3095
  var FILE_TOKEN_RE = /[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+/g;
@@ -2743,7 +3119,20 @@ var STOP_WORDS = /* @__PURE__ */ new Set(
2743
3119
  "what",
2744
3120
  "where",
2745
3121
  "why",
2746
- "with"
3122
+ "with",
3123
+ "src",
3124
+ "lib",
3125
+ "test",
3126
+ "tests",
3127
+ "spec",
3128
+ "fix",
3129
+ "explain",
3130
+ "please",
3131
+ "help",
3132
+ "current",
3133
+ "change",
3134
+ "changes",
3135
+ "code"
2747
3136
  ]
2748
3137
  );
2749
3138
  var CHANGE_RE = /\b(fix|bug|broken|breaks?|change|changed|current|working\s+tree|regression|impact|modified|duplicate|fails?|failure)\b/i;
@@ -2768,9 +3157,6 @@ function clamp2(value, min, max, fallback) {
2768
3157
  function wordBoundary(name2) {
2769
3158
  return new RegExp(`(^|[^A-Za-z0-9_$])${name2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[^A-Za-z0-9_$])`);
2770
3159
  }
2771
- function symbolLabel2(symbol) {
2772
- return `${symbol.kind} ${symbol.signature || symbol.name} \u2014 ${symbol.file}:${symbol.line}`;
2773
- }
2774
3160
  function isTestFile(file) {
2775
3161
  return TEST_PATH_RE2.test(file);
2776
3162
  }
@@ -2781,6 +3167,13 @@ function fileMentionMatches(file, mentions) {
2781
3167
  return lower === normalized || lower.endsWith(`/${normalized}`) || lower.includes(normalized);
2782
3168
  });
2783
3169
  }
3170
+ function contextTerms(text) {
3171
+ return unique(text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 3 && !STOP_WORDS.has(term)).flatMap((term) => [
3172
+ term,
3173
+ ...term.length > 5 && term.endsWith("ing") ? [term.slice(0, -3)] : [],
3174
+ ...term.length > 4 && term.endsWith("s") ? [term.slice(0, -1)] : []
3175
+ ]), (term) => term);
3176
+ }
2784
3177
  function routeTask(task, index) {
2785
3178
  const text = task.trim();
2786
3179
  const mentionedFiles = unique(text.match(FILE_TOKEN_RE) ?? [], (value) => value.toLowerCase()).map((value) => value.replace(/\\/g, "/")).filter((value) => !index || index.files.some((file) => fileMentionMatches(file.path, [value])));
@@ -2793,12 +3186,12 @@ function routeTask(task, index) {
2793
3186
  (token) => token.toLowerCase()
2794
3187
  );
2795
3188
  const queries = unique(
2796
- [...exactSymbolNames, ...codeTokens.filter((token) => !exactSymbolNames.includes(token))],
3189
+ [...exactSymbolNames, ...contextTerms(text), ...codeTokens.filter((token) => !exactSymbolNames.includes(token))],
2797
3190
  (value) => value.toLowerCase()
2798
3191
  ).slice(0, 12);
2799
3192
  const kind = TEST_RE3.test(text) || mentionedFiles.some(isTestFile) ? "test" : CHANGE_RE.test(text) ? "change" : exactSymbolNames.length > 0 ? "symbol" : EXPLORE_RE.test(text) ? "exploration" : ARCH_RE.test(text) ? "architecture" : "ambiguous";
2800
3193
  const includeChangeContext = kind === "change" || kind === "test";
2801
- const includeRelationships = kind === "change" || kind === "test" || kind === "symbol";
3194
+ const includeRelationships = kind !== "ambiguous";
2802
3195
  const sources = /* @__PURE__ */ new Set();
2803
3196
  if (kind === "change" || kind === "test") sources.add("change context");
2804
3197
  if (kind === "architecture" || kind === "exploration" || kind === "ambiguous") {
@@ -2827,34 +3220,61 @@ function routeScore(route, hit) {
2827
3220
  }
2828
3221
  function gatherPrimarySymbols(index, route, maxSymbols, changedIds, changedFiles, refs) {
2829
3222
  const hits = [];
3223
+ const queryTerms = new Set(route.queries.flatMap(contextTerms));
3224
+ for (const file of index.files) for (const symbol of file.symbols) {
3225
+ const terms = contextTerms(`${symbol.name} ${symbol.signature} ${file.path}`);
3226
+ const matched = terms.filter((term) => queryTerms.has(term)).length;
3227
+ if (route.exactSymbolNames.includes(symbol.name) || matched > 0 || fileMentionMatches(file.path, route.mentionedFiles)) {
3228
+ hits.push({ ...symbol, score: route.exactSymbolNames.includes(symbol.name) ? 1 : Math.min(0.8, matched * 0.2) });
3229
+ }
3230
+ }
2830
3231
  for (const query of route.queries) {
2831
3232
  hits.push(...searchSymbols(index, { query }, Math.max(maxSymbols * 3, 20), refs));
2832
3233
  }
2833
3234
  const byId = /* @__PURE__ */ new Map();
2834
3235
  for (const hit of hits) {
2835
3236
  const exact = route.exactSymbolNames.includes(hit.name);
3237
+ const overlap = contextTerms(`${hit.name} ${hit.signature} ${hit.file}`).filter((term) => queryTerms.has(term)).length;
3238
+ if (!exact && route.kind === "ambiguous" && queryTerms.size > 1 && overlap < 2) continue;
2836
3239
  let score = routeScore(route, hit);
2837
3240
  if (changedIds.has(hit.id)) score += 80;
2838
3241
  else if (changedFiles.has(hit.file)) score += 55;
2839
3242
  if (route.kind === "test" && isTestFile(hit.file)) score += 35;
2840
- const candidate = { symbol: hit, score, provenance: exact ? "exact" : "lexical" };
3243
+ const candidate = {
3244
+ symbol: hit,
3245
+ score,
3246
+ provenance: exact ? "exact" : "lexical",
3247
+ reason: exact ? "task names this symbol" : "matches task terms"
3248
+ };
2841
3249
  const previous = byId.get(hit.id);
2842
3250
  if (!previous || candidate.score > previous.score) byId.set(hit.id, candidate);
2843
3251
  }
3252
+ if (!byId.size && (route.kind === "architecture" || route.kind === "exploration")) {
3253
+ for (const entry of rankRepoMap(index, { topFiles: 2, symbolsPerFile: 2 })) {
3254
+ for (const symbol of index.files.find((file) => file.path === entry.path)?.symbols.slice(0, 2) ?? []) {
3255
+ byId.set(symbol.id, { symbol, score: 5, provenance: "lexical", reason: "bounded architecture entry lead" });
3256
+ }
3257
+ }
3258
+ }
2844
3259
  if (route.includeChangeContext) {
3260
+ const matchedFiles = new Set([...byId.values()].map((candidate) => candidate.symbol.file));
2845
3261
  for (const file of index.files) {
2846
- if (!changedFiles.has(file.path)) continue;
3262
+ if (matchedFiles.size && !matchedFiles.has(file.path)) continue;
2847
3263
  for (const symbol of file.symbols) {
3264
+ if (!changedIds.has(symbol.id)) continue;
2848
3265
  if (byId.has(symbol.id)) continue;
2849
3266
  byId.set(symbol.id, {
2850
3267
  symbol,
2851
3268
  score: 70,
2852
- provenance: "lexical"
3269
+ provenance: "lexical",
3270
+ reason: "declaration intersects current change"
2853
3271
  });
2854
3272
  }
2855
3273
  }
2856
3274
  }
2857
- return [...byId.values()].sort(
3275
+ const definitionMatch = (candidate) => contextTerms(`${candidate.symbol.name} ${candidate.symbol.signature}`).some((term) => queryTerms.has(term));
3276
+ const hasDefinitionMatch = [...byId.values()].some(definitionMatch);
3277
+ return [...byId.values()].filter((candidate) => !hasDefinitionMatch || definitionMatch(candidate) || candidate.reason === "declaration intersects current change" || fileMentionMatches(candidate.symbol.file, route.mentionedFiles)).sort(
2858
3278
  (a, b) => b.score - a.score || Number(b.provenance === "exact") - Number(a.provenance === "exact") || a.symbol.file.localeCompare(b.symbol.file) || a.symbol.line - b.symbol.line || a.symbol.id.localeCompare(b.symbol.id)
2859
3279
  ).slice(0, maxSymbols);
2860
3280
  }
@@ -2877,20 +3297,22 @@ function collectRelationships(graph, primary) {
2877
3297
  key,
2878
3298
  text: edgeText(graph, edge, direction),
2879
3299
  resolution: edge.resolution,
3300
+ provenance: edge.provenance,
2880
3301
  priority
2881
3302
  });
2882
3303
  };
2883
3304
  for (const candidate of primary) {
2884
- for (const edge of graph.incoming.get(candidate.symbol.id) ?? []) add(edge, "caller", 100);
2885
- for (const edge of graph.outgoing.get(candidate.symbol.id) ?? []) add(edge, "callee", 90);
3305
+ const bounded = (edges) => [...edges].sort((a, b) => a.resolution.localeCompare(b.resolution) || a.callSite.file.localeCompare(b.callSite.file) || a.callSite.line - b.callSite.line).slice(0, 8);
3306
+ for (const edge of bounded(graph.incoming.get(candidate.symbol.id) ?? [])) add(edge, "caller", 100);
3307
+ for (const edge of bounded(graph.outgoing.get(candidate.symbol.id) ?? [])) add(edge, "callee", 90);
2886
3308
  }
2887
3309
  return rows.sort(
2888
3310
  (a, b) => b.priority - a.priority || a.resolution.localeCompare(b.resolution) || a.text.localeCompare(b.text)
2889
3311
  );
2890
3312
  }
2891
- function addFile(files, path9, score, reason) {
2892
- const previous = files.get(path9);
2893
- if (!previous || score > previous.score) files.set(path9, { path: path9, score, reason });
3313
+ function addFile(files, path11, score, reason) {
3314
+ const previous = files.get(path11);
3315
+ if (!previous || score > previous.score) files.set(path11, { path: path11, score, reason });
2894
3316
  }
2895
3317
  function gatherFiles(index, route, primary, change, maxFiles) {
2896
3318
  const files = /* @__PURE__ */ new Map();
@@ -2913,7 +3335,7 @@ function gatherFiles(index, route, primary, change, maxFiles) {
2913
3335
  addFile(files, pathRow.changed.file, 90, "changed path");
2914
3336
  }
2915
3337
  }
2916
- for (const entry of rankRepoMap(index, { topFiles: maxFiles * 2, symbolsPerFile: 8 })) {
3338
+ if (!primary.length) for (const entry of rankRepoMap(index, { topFiles: maxFiles * 2, symbolsPerFile: 8 })) {
2917
3339
  addFile(files, entry.path, Math.min(35, entry.score + 10), "repo map");
2918
3340
  }
2919
3341
  return [...files.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, maxFiles);
@@ -2929,118 +3351,6 @@ function confidenceOf(primary, relationships, change) {
2929
3351
  }
2930
3352
  return out;
2931
3353
  }
2932
- function renderRows(result, budgetChars) {
2933
- const lines = [
2934
- `Task context \u2014 ${result.route.kind}`,
2935
- `Task: ${result.task}`,
2936
- `Sources: ${result.route.sources.join(", ")}`,
2937
- "",
2938
- "Primary symbols:"
2939
- ];
2940
- if (result.primarySymbols.length === 0) lines.push("- (none found)");
2941
- const optional = [];
2942
- for (const candidate2 of result.primarySymbols) {
2943
- optional.push({
2944
- priority: 1e3 + candidate2.score,
2945
- key: `symbol:${candidate2.symbol.id}`,
2946
- section: "primary",
2947
- line: `- [${candidate2.provenance}] ${symbolLabel2(candidate2.symbol)}`
2948
- });
2949
- }
2950
- optional.push({ priority: 700, key: "files-header", section: "files", line: "\nRelevant files:" });
2951
- for (const file of result.relevantFiles) {
2952
- optional.push({ priority: 500 + file.score, key: `file:${file.path}`, section: "files", line: `- ${file.path} (${file.reason})` });
2953
- }
2954
- optional.push({ priority: 400, key: "relationships-header", section: "relationships", line: "\nRelationships:" });
2955
- for (const relation of result.relationships) {
2956
- optional.push({ priority: relation.priority, key: `relation:${relation.key}`, section: "relationships", line: `- ${relation.text}` });
2957
- }
2958
- if (result.changeContext) {
2959
- optional.push({ priority: 850, key: "changes-header", section: "changes", line: "\nCurrent changes:" });
2960
- for (const changed of result.changeContext.changed) {
2961
- optional.push({
2962
- priority: 840,
2963
- key: `changed:${changed.side}:${changed.symbol.id}`,
2964
- section: "changes",
2965
- line: `- ${changed.change} ${symbolLabel2(changed.symbol)} [${changed.resolution}]`
2966
- });
2967
- }
2968
- if (result.changeContext.unmapped.length > 0) {
2969
- optional.push({
2970
- priority: 830,
2971
- key: "changes-unmapped",
2972
- section: "changes",
2973
- line: `- unmapped: ${result.changeContext.unmapped.map((entry) => entry.file).join(", ")}`
2974
- });
2975
- }
2976
- }
2977
- if (result.tests.length > 0) {
2978
- optional.push({ priority: 350, key: "tests-header", section: "tests", line: "\nLikely affected tests:" });
2979
- for (const test of result.tests) optional.push({ priority: 340, key: `test:${test}`, section: "tests", line: `- ${test}` });
2980
- }
2981
- if (result.warnings.length > 0) {
2982
- optional.push({ priority: 200, key: "warnings-header", section: "warnings", line: "\nWarnings:" });
2983
- for (const warning of result.warnings) optional.push({ priority: 190, key: `warning:${warning}`, section: "warnings", line: `- ${warning}` });
2984
- }
2985
- const ordered = optional.map((item, index) => ({ ...item, index })).sort((a, b) => b.priority - a.priority || a.index - b.index || a.key.localeCompare(b.key));
2986
- const footerReserve = 100;
2987
- let truncated = false;
2988
- const seen = /* @__PURE__ */ new Set();
2989
- for (const item of ordered) {
2990
- if (seen.has(item.key)) continue;
2991
- const next = lines.join("\n") + `
2992
- ${item.line}`;
2993
- if (next.length + footerReserve > budgetChars) {
2994
- truncated = true;
2995
- continue;
2996
- }
2997
- seen.add(item.key);
2998
- lines.push(item.line);
2999
- }
3000
- lines.push(
3001
- "",
3002
- `Confidence: exact ${result.confidence.exact}; import-scoped ${result.confidence["import-scoped"]}; name-only ${result.confidence["name-only"]}`
3003
- );
3004
- const body = lines.join("\n");
3005
- const withFooter = (bodyText, wasTruncated) => {
3006
- let usedChars = bodyText.length + 1;
3007
- for (let iteration = 0; iteration < 10; iteration++) {
3008
- const footer2 = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3009
- const text2 = `${bodyText}
3010
- ${footer2}`;
3011
- const nextUsedChars = text2.length;
3012
- if (nextUsedChars === usedChars) return { text: text2, usedChars: nextUsedChars };
3013
- usedChars = nextUsedChars;
3014
- }
3015
- const footer = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3016
- const text = `${bodyText}
3017
- ${footer}`;
3018
- return { text, usedChars: text.length };
3019
- };
3020
- const complete = withFooter(body, truncated);
3021
- if (complete.usedChars <= budgetChars) {
3022
- return { text: complete.text, usedChars: complete.usedChars, truncated };
3023
- }
3024
- truncated = true;
3025
- const candidate = (prefixLength) => {
3026
- const prefix = `${body.slice(0, prefixLength).trimEnd()}\u2026`;
3027
- return withFooter(prefix, true);
3028
- };
3029
- let low = 0;
3030
- let high = body.length;
3031
- let best = candidate(0);
3032
- while (low <= high) {
3033
- const middle = Math.floor((low + high) / 2);
3034
- const current = candidate(middle);
3035
- if (current.usedChars <= budgetChars) {
3036
- best = current;
3037
- low = middle + 1;
3038
- } else {
3039
- high = middle - 1;
3040
- }
3041
- }
3042
- return { text: best.text, usedChars: best.usedChars, truncated };
3043
- }
3044
3354
  async function buildTaskContext(index, task, options = {}) {
3045
3355
  const budgetChars = clamp2(options.budgetChars, 300, 2e4, 5e3);
3046
3356
  const maxFiles = clamp2(options.maxFiles, 1, 50, 12);
@@ -3070,6 +3380,19 @@ async function buildTaskContext(index, task, options = {}) {
3070
3380
  changedFiles,
3071
3381
  callerCounts(index)
3072
3382
  );
3383
+ const taskFiles = new Set(primarySymbols.filter((seed) => seed.reason !== "declaration intersects current change").map((seed) => seed.symbol.file));
3384
+ if (changeContext && taskFiles.size) {
3385
+ const changed = changeContext.changed.filter((row) => taskFiles.has(row.symbol.file));
3386
+ const changedIds2 = new Set(changed.map((row) => row.symbol.id));
3387
+ changeContext = {
3388
+ ...changeContext,
3389
+ changed,
3390
+ directCallers: changeContext.directCallers.filter((row) => changedIds2.has(row.via.toId)),
3391
+ tests: collectSeedTests(index, changed.map((row) => row.symbol)),
3392
+ importDependents: changeContext.importDependents.filter((row) => taskFiles.has(row.changedFile)),
3393
+ paths: changeContext.paths.filter((row) => changedIds2.has(row.changed.id))
3394
+ };
3395
+ }
3073
3396
  const graph = route.includeRelationships ? buildReferenceGraph(index) : null;
3074
3397
  const relationships = graph ? collectRelationships(graph, primarySymbols) : [];
3075
3398
  if (changeContext) {
@@ -3082,13 +3405,18 @@ async function buildTaskContext(index, task, options = {}) {
3082
3405
  key,
3083
3406
  text: `${row.symbol.name} (${row.symbol.file}:${row.symbol.line}) \u2192 changed symbol at ${row.via.callSite.file}:${row.via.callSite.line} [${row.resolution}; ${row.via.reason}]`,
3084
3407
  resolution: row.resolution,
3408
+ provenance: row.via.reason,
3085
3409
  priority: 110
3086
3410
  });
3087
3411
  }
3088
3412
  relationships.sort((a, b) => b.priority - a.priority || a.text.localeCompare(b.text));
3089
3413
  }
3090
3414
  const relevantFiles = gatherFiles(index, route, primarySymbols, changeContext, maxFiles);
3091
- const tests = unique(changeContext?.tests.map((test) => test.file) ?? [], (file) => file);
3415
+ const testEvidence = unique([
3416
+ ...collectSeedTests(index, primarySymbols.filter((seed) => seed.provenance === "exact" || seed.score >= 20).map((seed) => seed.symbol)),
3417
+ ...changeContext?.tests ?? []
3418
+ ], (test) => test.file).slice(0, 8);
3419
+ const tests = testEvidence.map((test) => test.file);
3092
3420
  const confidence = confidenceOf(primarySymbols, relationships, changeContext);
3093
3421
  const partial = {
3094
3422
  root: index.root,
@@ -3099,29 +3427,64 @@ async function buildTaskContext(index, task, options = {}) {
3099
3427
  relationships,
3100
3428
  changeContext,
3101
3429
  tests,
3430
+ testEvidence,
3102
3431
  confidence,
3103
3432
  warnings
3104
3433
  };
3105
- const rendered = renderRows(partial, budgetChars);
3434
+ const pack = await selectContextPack(index, partial, budgetChars, maxFiles);
3435
+ const selected = (file, name2, kind) => pack.items.some((item) => item.file === file && (!name2 || item.name === name2) && (!kind || item.kind === kind));
3436
+ const selectedRelationships = relationships.filter((row) => pack.items.some((item) => item.text === row.text));
3437
+ const selectedChange = changeContext ? {
3438
+ ...changeContext,
3439
+ changed: changeContext.changed.filter((row) => pack.items.some((item) => item.file === row.symbol.file && item.name === row.symbol.name && item.side === row.side && item.change === row.change)),
3440
+ unmapped: [],
3441
+ directCallers: [],
3442
+ importDependents: [],
3443
+ paths: [],
3444
+ impact: [],
3445
+ tests: changeContext.tests.filter((test) => selected(test.file, void 0, "test")),
3446
+ warnings: pack.gaps
3447
+ } : null;
3106
3448
  return {
3107
3449
  ...partial,
3108
- budget: {
3109
- usedChars: rendered.usedChars,
3110
- budgetChars,
3111
- truncated: rendered.truncated
3112
- }
3450
+ pack,
3451
+ route: {
3452
+ ...route,
3453
+ exactSymbolNames: route.exactSymbolNames.filter((name2) => pack.items.some((item) => item.name === name2)),
3454
+ mentionedFiles: route.mentionedFiles.filter((file) => pack.items.some((item) => item.file && fileMentionMatches(item.file, [file])))
3455
+ },
3456
+ primarySymbols: primarySymbols.filter((seed) => selected(seed.symbol.file, seed.symbol.name, "primary")),
3457
+ relevantFiles: relevantFiles.filter((file) => selected(file.path)),
3458
+ relationships: selectedRelationships,
3459
+ changeContext: selectedChange,
3460
+ tests: tests.filter((file) => selected(file, void 0, "test")),
3461
+ testEvidence: testEvidence.filter((test) => selected(test.file, void 0, "test")),
3462
+ confidence: confidenceOf([], selectedRelationships, null),
3463
+ warnings: pack.gaps,
3464
+ budget: { usedChars: pack.budget.usedChars, budgetChars, truncated: pack.budget.truncated }
3113
3465
  };
3114
3466
  }
3115
3467
  function renderTaskContext(result) {
3116
- const rendered = renderRows(result, result.budget.budgetChars);
3117
- return rendered.text;
3468
+ return renderContextPack(result.pack);
3118
3469
  }
3119
3470
 
3120
3471
  // src/tools.ts
3121
3472
  import { defineTool } from "@deepseek-ai/dsh-tools";
3122
- import path8 from "path";
3473
+ import path10 from "path";
3123
3474
 
3124
3475
  // src/config.ts
3476
+ import Schema from "@deepseek-ai/schemastery";
3477
+ var Config = Schema.object({
3478
+ excludeDirs: Schema.array(Schema.string()).default([]).volatile(),
3479
+ mapTopFiles: Schema.number().default(24).volatile(),
3480
+ mapMaxChars: Schema.number().default(3200).volatile(),
3481
+ mapTtlMs: Schema.number().default(6e4).volatile(),
3482
+ autoInject: Schema.boolean().default(true).volatile(),
3483
+ codeHealth: Schema.boolean().default(false).volatile(),
3484
+ toolSurface: Schema.string().default("full").volatile(),
3485
+ externalWatch: Schema.boolean().default(true).volatile(),
3486
+ watchDebounceMs: Schema.number().default(120).volatile()
3487
+ });
3125
3488
  var DEFAULTS = {
3126
3489
  excludeDirs: [],
3127
3490
  mapTopFiles: 24,
@@ -3129,14 +3492,17 @@ var DEFAULTS = {
3129
3492
  mapTtlMs: 6e4,
3130
3493
  autoInject: true,
3131
3494
  codeHealth: false,
3132
- toolSurface: "full"
3495
+ toolSurface: "full",
3496
+ externalWatch: true,
3497
+ watchDebounceMs: 120
3133
3498
  };
3134
3499
  var state = { current: { ...DEFAULTS } };
3135
3500
  function applyConfig(partial) {
3501
+ const resolved = partial ? Object.fromEntries(Object.entries(partial).map(([key, value]) => [key, unwrapVolatile(value)])) : void 0;
3136
3502
  state.current = {
3137
3503
  ...DEFAULTS,
3138
- ...partial ?? {},
3139
- excludeDirs: [...DEFAULTS.excludeDirs, ...partial?.excludeDirs ?? []]
3504
+ ...resolved ?? {},
3505
+ excludeDirs: [...DEFAULTS.excludeDirs, ...resolved?.excludeDirs ?? []]
3140
3506
  };
3141
3507
  if (!Number.isFinite(state.current.mapTopFiles) || state.current.mapTopFiles < 1) {
3142
3508
  state.current.mapTopFiles = DEFAULTS.mapTopFiles;
@@ -3150,6 +3516,16 @@ function applyConfig(partial) {
3150
3516
  if (state.current.toolSurface !== "full" && state.current.toolSurface !== "compact") {
3151
3517
  state.current.toolSurface = DEFAULTS.toolSurface;
3152
3518
  }
3519
+ if (typeof state.current.externalWatch !== "boolean") state.current.externalWatch = DEFAULTS.externalWatch;
3520
+ if (!Number.isFinite(state.current.watchDebounceMs) || state.current.watchDebounceMs < 20) {
3521
+ state.current.watchDebounceMs = DEFAULTS.watchDebounceMs;
3522
+ }
3523
+ }
3524
+ function unwrapVolatile(value) {
3525
+ if (typeof value !== "object" || value === null || !("get" in value) || typeof value.get !== "function") {
3526
+ return value;
3527
+ }
3528
+ return value.get();
3153
3529
  }
3154
3530
  function getConfig() {
3155
3531
  return state.current;
@@ -3158,67 +3534,192 @@ function indexOptions() {
3158
3534
  return { excludeDirs: state.current.excludeDirs };
3159
3535
  }
3160
3536
 
3161
- // src/tools.ts
3162
- function createIndexCache(load, ttlMs = 6e4, now = Date.now) {
3163
- const completed = /* @__PURE__ */ new Map();
3164
- const inFlight = /* @__PURE__ */ new Map();
3165
- let epoch = 0;
3166
- function startLoad(key, root, previous) {
3167
- const loadEpoch = epoch;
3168
- const begin = previous ? previous.catch(() => void 0).then(() => load(root)) : load(root);
3169
- const promise = begin.then((index) => {
3170
- if (loadEpoch === epoch) {
3171
- completed.delete(key);
3172
- completed.set(key, { index, at: now(), epoch: loadEpoch });
3173
- while (completed.size > 16) completed.delete(completed.keys().next().value);
3537
+ // src/repo-context.ts
3538
+ import { realpath as realpath2 } from "fs/promises";
3539
+ import path9 from "path";
3540
+ import chokidar from "chokidar";
3541
+ function ignoredRelativePath(rel, options) {
3542
+ const components = rel.split(/[\\/]+/).filter(Boolean);
3543
+ const excluded = /* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs ?? []]);
3544
+ return components.some((component) => excluded.has(component));
3545
+ }
3546
+ async function canonicalRepoRoot(root) {
3547
+ const resolved = await realpath2(path9.resolve(root));
3548
+ return path9.normalize(resolved);
3549
+ }
3550
+ var RepoContextManager = class {
3551
+ contexts = /* @__PURE__ */ new Map();
3552
+ options;
3553
+ disposed = false;
3554
+ constructor(options) {
3555
+ this.options = {
3556
+ indexOptions: options.indexOptions,
3557
+ watch: options.watch ?? true,
3558
+ debounceMs: Math.max(20, options.debounceMs ?? 120),
3559
+ maxContexts: Math.max(1, options.maxContexts ?? 4),
3560
+ onRefresh: options.onRefresh
3561
+ };
3562
+ }
3563
+ async get(root, force = false) {
3564
+ if (this.disposed) throw new Error("repo context manager is disposed");
3565
+ const canonical = await canonicalRepoRoot(root);
3566
+ const key = cacheKeyForRoot(canonical);
3567
+ let context = this.contexts.get(key);
3568
+ if (!context) {
3569
+ context = {
3570
+ root: canonical,
3571
+ index: null,
3572
+ lastRefreshAt: 0,
3573
+ dirtyFiles: /* @__PURE__ */ new Set(),
3574
+ ignoreMatcherCache: /* @__PURE__ */ new Map(),
3575
+ watcher: null,
3576
+ debounceTimer: null,
3577
+ refresh: null,
3578
+ disposed: false,
3579
+ generation: 0,
3580
+ lastUsedAt: Date.now()
3581
+ };
3582
+ this.contexts.set(key, context);
3583
+ this.attachWatcher(context);
3584
+ this.evictInactive();
3585
+ } else {
3586
+ this.contexts.delete(key);
3587
+ this.contexts.set(key, context);
3588
+ context.lastUsedAt = Date.now();
3589
+ }
3590
+ let index = await this.refresh(context, force);
3591
+ while (!context.disposed && context.dirtyFiles.size > 0) {
3592
+ if (context.refresh) await context.refresh;
3593
+ else index = await this.refresh(context);
3594
+ }
3595
+ return index;
3596
+ }
3597
+ getContext(root) {
3598
+ const key = cacheKeyForRoot(path9.resolve(root));
3599
+ return this.contexts.get(key);
3600
+ }
3601
+ async dispose() {
3602
+ if (this.disposed) return;
3603
+ this.disposed = true;
3604
+ const contexts = [...this.contexts.values()];
3605
+ this.contexts.clear();
3606
+ await Promise.all(contexts.map((context) => this.disposeContext(context)));
3607
+ }
3608
+ reconfigure(options) {
3609
+ if (options.debounceMs !== void 0) this.options.debounceMs = Math.max(20, options.debounceMs);
3610
+ if (options.watch === void 0 || options.watch === this.options.watch) return;
3611
+ this.options.watch = options.watch;
3612
+ for (const context of this.contexts.values()) {
3613
+ if (options.watch) this.attachWatcher(context);
3614
+ else {
3615
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3616
+ context.debounceTimer = null;
3617
+ const watcher = context.watcher;
3618
+ context.watcher = null;
3619
+ void watcher?.close();
3620
+ }
3621
+ }
3622
+ }
3623
+ async refresh(context, force = false) {
3624
+ if (context.refresh) {
3625
+ const index = await context.refresh;
3626
+ return force ? this.refresh(context, true) : index;
3627
+ }
3628
+ const generation = context.generation;
3629
+ const dirty = force ? /* @__PURE__ */ new Set(["*"]) : new Set(context.dirtyFiles);
3630
+ context.dirtyFiles.clear();
3631
+ const promise = buildIndexWithCache(context.root, this.options.indexOptions(), void 0, dirty).then((index) => {
3632
+ if (!context.disposed && context.generation === generation) {
3633
+ context.index = index;
3634
+ context.lastRefreshAt = Date.now();
3635
+ this.options.onRefresh?.(context.root, index);
3174
3636
  }
3175
3637
  return index;
3176
3638
  }).finally(() => {
3177
- if (inFlight.get(key)?.promise === promise) inFlight.delete(key);
3639
+ if (context.refresh === promise) context.refresh = null;
3640
+ if (!context.disposed && context.dirtyFiles.size > 0) this.scheduleRefresh(context);
3178
3641
  });
3179
- inFlight.set(key, { promise, epoch: loadEpoch });
3642
+ context.refresh = promise;
3180
3643
  return promise;
3181
3644
  }
3182
- return {
3183
- get(root, force = false) {
3184
- const resolvedRoot = path8.resolve(root);
3185
- const key = cacheKeyForRoot(resolvedRoot);
3186
- const running = inFlight.get(key);
3187
- if (running) {
3188
- if (running.epoch === epoch && !force) return running.promise;
3189
- return startLoad(key, resolvedRoot, running.promise);
3190
- }
3191
- if (!force) {
3192
- const cached = completed.get(key);
3193
- if (cached && cached.epoch === epoch && now() - cached.at < ttlMs) {
3194
- completed.delete(key);
3195
- completed.set(key, cached);
3196
- return Promise.resolve(cached.index);
3197
- }
3198
- if (cached) completed.delete(key);
3199
- } else {
3200
- completed.delete(key);
3201
- }
3202
- return startLoad(key, resolvedRoot);
3203
- },
3204
- invalidate() {
3205
- epoch++;
3206
- completed.clear();
3645
+ attachWatcher(context) {
3646
+ if (!this.options.watch) return;
3647
+ const options = this.options.indexOptions();
3648
+ const ignored = (absolute) => {
3649
+ const rel = path9.relative(context.root, absolute);
3650
+ return rel === ".." || rel.startsWith(`..${path9.sep}`) || path9.isAbsolute(rel) || ignoredRelativePath(rel, options) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache);
3651
+ };
3652
+ const watcher = chokidar.watch(context.root, {
3653
+ ignored,
3654
+ ignoreInitial: true,
3655
+ persistent: true,
3656
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 20 },
3657
+ followSymlinks: false
3658
+ });
3659
+ context.watcher = watcher;
3660
+ watcher.on("all", (event, absolute) => {
3661
+ if (context.disposed || !["add", "change", "unlink"].includes(event)) return;
3662
+ const rel = path9.relative(context.root, absolute).split(path9.sep).join("/");
3663
+ if (!rel || ignoredRelativePath(rel, this.options.indexOptions()) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache)) return;
3664
+ if (!SUPPORTED_EXTS.has(path9.extname(rel).toLowerCase())) return;
3665
+ context.dirtyFiles.add(rel);
3666
+ this.scheduleRefresh(context);
3667
+ });
3668
+ watcher.on("error", (error) => {
3669
+ if (!context.disposed) console.warn(`[dsh-code-index] watcher failed for ${context.root}: ${String(error)}`);
3670
+ });
3671
+ }
3672
+ scheduleRefresh(context) {
3673
+ if (context.disposed || this.disposed) return;
3674
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3675
+ context.debounceTimer = setTimeout(() => {
3676
+ context.debounceTimer = null;
3677
+ void this.refresh(context).catch((error) => {
3678
+ if (!context.disposed) console.warn(`[dsh-code-index] refresh failed for ${context.root}: ${String(error)}`);
3679
+ });
3680
+ }, this.options.debounceMs);
3681
+ }
3682
+ evictInactive() {
3683
+ while (this.contexts.size > this.options.maxContexts) {
3684
+ const oldest = this.contexts.entries().next().value;
3685
+ if (!oldest) return;
3686
+ this.contexts.delete(oldest[0]);
3687
+ void this.disposeContext(oldest[1]);
3207
3688
  }
3208
- };
3689
+ }
3690
+ async disposeContext(context) {
3691
+ context.disposed = true;
3692
+ context.generation++;
3693
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3694
+ context.debounceTimer = null;
3695
+ const watcher = context.watcher;
3696
+ context.watcher = null;
3697
+ if (watcher) await watcher.close();
3698
+ await context.refresh?.catch(() => void 0);
3699
+ context.dirtyFiles.clear();
3700
+ context.ignoreMatcherCache.clear();
3701
+ context.index = null;
3702
+ }
3703
+ };
3704
+
3705
+ // src/tools.ts
3706
+ var activeRepoContexts = null;
3707
+ function activateRepoContextManager(manager) {
3708
+ activeRepoContexts = manager;
3709
+ }
3710
+ function clearRepoContextManager(manager) {
3711
+ if (activeRepoContexts === manager) activeRepoContexts = null;
3209
3712
  }
3210
- var indexCache = createIndexCache((root) => buildIndexWithCache(root, indexOptions()));
3211
3713
  function getIndex(root, force = false) {
3212
- return indexCache.get(root, force);
3714
+ return activeRepoContexts ? activeRepoContexts.get(root, force) : buildIndexWithCache(root, indexOptions(), void 0, force ? /* @__PURE__ */ new Set(["*"]) : void 0);
3213
3715
  }
3214
3716
  function invalidateIndexCache() {
3215
- indexCache.invalidate();
3216
3717
  }
3217
3718
  async function resolveRoot(arg, exec) {
3218
- const cwd = exec.agent?.session?.header?.cwd;
3719
+ const cwd = exec.agent?.session?.header?.cwd ?? exec.session?.header?.cwd ?? exec.cwd;
3219
3720
  const base = arg ?? cwd ?? process.cwd();
3220
3721
  const root = await findRepoRoot(base);
3221
- if (!root) throw new Error(`no git repository found from ${path8.resolve(base)}`);
3722
+ if (!root) throw new Error(`no git repository found from ${path10.resolve(base)}`);
3222
3723
  return root;
3223
3724
  }
3224
3725
  function hitsToSearchMeta(hits) {
@@ -3233,7 +3734,7 @@ function hitsToSearchMeta(hits) {
3233
3734
  }
3234
3735
  return {
3235
3736
  shape: "matches",
3236
- files: [...byFile.entries()].map(([path9, matches]) => ({ path: path9, matches })),
3737
+ files: [...byFile.entries()].map(([path11, matches]) => ({ path: path11, matches })),
3237
3738
  truncated: false,
3238
3739
  total: hits.length
3239
3740
  };
@@ -3565,6 +4066,11 @@ var tools = [
3565
4066
  name: "code_context",
3566
4067
  description: "Return the smallest useful structural context for a code task. Pass the task in plain language; the deterministic router combines ranked symbols, relevant files, call/import relationships, repository structure, current Git changes, affected tests, provenance, and a hard character budget. Existing specialized tools remain available for focused follow-up queries.",
3567
4068
  parameters: {
4069
+ outputFormat: {
4070
+ type: "string",
4071
+ enum: ["text", "pack"],
4072
+ description: "Default text preserves the canonical string return; pack explicitly requests the selected ContextPack DTO."
4073
+ },
3568
4074
  task: {
3569
4075
  type: "string",
3570
4076
  required: true,
@@ -3588,8 +4094,8 @@ var tools = [
3588
4094
  }
3589
4095
  },
3590
4096
  output: {
3591
- schema: { type: "string" },
3592
- render: (_args, value) => [{ type: "text", text: value }]
4097
+ schema: { oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }] },
4098
+ render: (_args, value) => [{ type: "text", text: typeof value === "string" ? value : renderContextPack(value) }]
3593
4099
  },
3594
4100
  presentCall: (args) => ({
3595
4101
  card: "generic",
@@ -3609,9 +4115,9 @@ var tools = [
3609
4115
  maxFiles: args.maxFiles,
3610
4116
  maxSymbols: args.maxSymbols
3611
4117
  });
3612
- return renderTaskContext(result);
4118
+ return args.outputFormat === "pack" ? JSON.parse(JSON.stringify(result.pack)) : renderTaskContext(result);
3613
4119
  } catch (error) {
3614
- return `code_context: ${error.message ?? String(error)}`;
4120
+ return `code_context: ${(error.message ?? String(error)).slice(0, 240)}`;
3615
4121
  }
3616
4122
  }
3617
4123
  }),
@@ -3656,25 +4162,32 @@ var tools = [
3656
4162
  ];
3657
4163
 
3658
4164
  // src/settings.ts
3659
- import Schema from "@deepseek-ai/schemastery";
4165
+ import Schema2 from "@deepseek-ai/schemastery";
3660
4166
  var SETTINGS_NAMESPACE = "code-index";
3661
- var settingsSchema = Schema.object({
3662
- excludeDirs: Schema.array(Schema.string()).default([]),
3663
- mapTopFiles: Schema.number().default(24),
3664
- mapMaxChars: Schema.number().default(3200),
3665
- mapTtlMs: Schema.number().default(6e4),
3666
- autoInject: Schema.boolean().default(true),
3667
- codeHealth: Schema.boolean().default(false),
3668
- toolSurface: Schema.string().default("full")
4167
+ var settingsSchema = Schema2.object({
4168
+ excludeDirs: Schema2.array(Schema2.string()).default([]),
4169
+ mapTopFiles: Schema2.number().default(24),
4170
+ mapMaxChars: Schema2.number().default(3200),
4171
+ mapTtlMs: Schema2.number().default(6e4),
4172
+ autoInject: Schema2.boolean().default(true),
4173
+ codeHealth: Schema2.boolean().default(false),
4174
+ toolSurface: Schema2.string().default("full"),
4175
+ externalWatch: Schema2.boolean().default(true),
4176
+ watchDebounceMs: Schema2.number().default(120)
3669
4177
  });
3670
- function registerSettings(getService, pluginConfig, onResolved) {
4178
+ function registerSettings(getService, pluginConfig, onResolved, fiber) {
3671
4179
  const service = getService("settings");
3672
- if (!service) return;
4180
+ if (!service) return void 0;
4181
+ if (service.configure) {
4182
+ const dispose = service.configure({ auto: false }, fiber);
4183
+ onResolved(pluginConfig ?? {});
4184
+ return typeof dispose === "function" ? dispose : void 0;
4185
+ }
3673
4186
  const scope = service.register(SETTINGS_NAMESPACE, settingsSchema, {
3674
4187
  base: pluginConfig ?? {}
3675
4188
  });
3676
4189
  onResolved(scope.get());
3677
- scope.watch((next) => {
4190
+ return scope.watch((next) => {
3678
4191
  onResolved(next);
3679
4192
  });
3680
4193
  }
@@ -3687,30 +4200,51 @@ function apply(ctx, pluginConfig) {
3687
4200
  invalidateIndexCache();
3688
4201
  ctx.effect(() => {
3689
4202
  const disposers = [];
4203
+ const repoContexts = new RepoContextManager({
4204
+ indexOptions,
4205
+ watch: getConfig().externalWatch,
4206
+ debounceMs: getConfig().watchDebounceMs
4207
+ });
4208
+ activateRepoContextManager(repoContexts);
4209
+ const updateConfig = (next) => {
4210
+ applyConfig(next);
4211
+ repoContexts.reconfigure({ watch: getConfig().externalWatch, debounceMs: getConfig().watchDebounceMs });
4212
+ };
3690
4213
  console.log("[dsh-code-index] plugin loaded");
3691
4214
  if (ctx.inject) {
3692
- ctx.inject(["settings"], (settingsCtx) => {
3693
- registerSettings(
4215
+ const injected = ctx.inject(["settings"], (settingsCtx) => {
4216
+ const dispose = registerSettings(
3694
4217
  (name2) => settingsCtx.get(name2),
3695
4218
  pluginConfig,
3696
- (resolved) => applyConfig(resolved)
4219
+ (resolved) => updateConfig(resolved),
4220
+ settingsCtx.fiber
3697
4221
  );
4222
+ if (dispose) disposers.push(dispose);
3698
4223
  });
4224
+ if (typeof injected === "function") disposers.push(injected);
3699
4225
  }
4226
+ const removeVolatileListener = ctx.on?.("loader/volatile-update", () => updateConfig(pluginConfig));
4227
+ if (removeVolatileListener) disposers.push(removeVolatileListener);
3700
4228
  const visibleTools = getConfig().toolSurface === "compact" ? tools.filter((tool) => ["code_index", "code_context", "code_health"].includes(tool.name)) : tools;
3701
4229
  for (const tool of visibleTools) {
3702
4230
  if (tool.name === "code_health" && !getConfig().codeHealth) continue;
3703
4231
  disposers.push(ctx.tools.register(tool));
3704
4232
  console.log(`[dsh-code-index] registered tool: ${tool.name}`);
3705
4233
  }
3706
- let cached = null;
3707
- async function warmMap() {
4234
+ const cachedMaps = /* @__PURE__ */ new Map();
4235
+ const saveMap = (key, value) => {
4236
+ cachedMaps.delete(key);
4237
+ cachedMaps.set(key, value);
4238
+ while (cachedMaps.size > 4) cachedMaps.delete(cachedMaps.keys().next().value);
4239
+ };
4240
+ async function warmMap(base) {
3708
4241
  const now = Date.now();
3709
4242
  const cfg = getConfig();
4243
+ const key = cacheKeyForRoot(base);
3710
4244
  try {
3711
- const root = await findRepoRoot(process.cwd());
4245
+ const root = await findRepoRoot(base);
3712
4246
  if (!root) {
3713
- cached = { root: "", at: now, text: "" };
4247
+ saveMap(key, { root: "", at: now, text: "" });
3714
4248
  return;
3715
4249
  }
3716
4250
  const index = await getIndex(root);
@@ -3719,27 +4253,34 @@ function apply(ctx, pluginConfig) {
3719
4253
  { maxChars: cfg.mapMaxChars }
3720
4254
  );
3721
4255
  const stats = symbolCount(index);
3722
- cached = {
4256
+ saveMap(key, {
3723
4257
  root: index.root,
3724
4258
  at: Date.now(),
3725
- text: text ? `${text}
4259
+ text: text ? `Project: ${index.root}
4260
+ ${text}
3726
4261
 
3727
4262
  (summary: ${index.files.length} files, ${stats} symbols)` : ""
3728
- };
4263
+ });
3729
4264
  } catch {
3730
- cached = { root: "", at: now, text: "" };
4265
+ saveMap(key, { root: "", at: now, text: "" });
3731
4266
  }
3732
4267
  }
3733
- void warmMap();
3734
4268
  if (getConfig().autoInject) {
3735
4269
  disposers.push(ctx.systemPrompt.section({
3736
4270
  name: "code-index:repo-map",
3737
4271
  order: 60,
3738
4272
  // before tool guidance (100–199), after persona (0)
3739
- text: () => {
4273
+ text: (rawContext) => {
4274
+ const context = rawContext;
4275
+ const base = context.agent?.session?.header?.cwd ?? context.session?.header?.cwd ?? process.cwd();
4276
+ const key = cacheKeyForRoot(base);
3740
4277
  const now = Date.now();
3741
- if (cached && now - cached.at < getConfig().mapTtlMs) return cached.text;
3742
- void warmMap();
4278
+ const cached = cachedMaps.get(key);
4279
+ if (cached && now - cached.at < getConfig().mapTtlMs) {
4280
+ saveMap(key, cached);
4281
+ return cached.text;
4282
+ }
4283
+ void warmMap(base);
3743
4284
  return cached?.text ?? "";
3744
4285
  }
3745
4286
  }));
@@ -3753,6 +4294,9 @@ function apply(ctx, pluginConfig) {
3753
4294
  errors.push(error);
3754
4295
  }
3755
4296
  }
4297
+ clearRepoContextManager(repoContexts);
4298
+ void repoContexts.dispose();
4299
+ cachedMaps.clear();
3756
4300
  console.log("[dsh-code-index] plugin unloaded");
3757
4301
  if (errors.length > 0) throw new AggregateError(errors, "failed to unload dsh-code-index");
3758
4302
  };
@@ -3760,6 +4304,7 @@ function apply(ctx, pluginConfig) {
3760
4304
  }
3761
4305
  export {
3762
4306
  CACHE_DIR_NAME,
4307
+ Config,
3763
4308
  DEFAULT_EXCLUDED_DIRS,
3764
4309
  SUPPORTED_EXTS,
3765
4310
  apply,
@@ -3787,6 +4332,7 @@ export {
3787
4332
  readGitFileAtRef,
3788
4333
  readWorkingTreeDiff,
3789
4334
  renderChangeContext,
4335
+ renderContextPack,
3790
4336
  renderHit,
3791
4337
  renderRepoMap,
3792
4338
  renderTaskContext,