dsh-code-index 0.6.1 → 0.8.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 path10 = node.childForFieldName("path");
804
804
  const alias = node.childForFieldName("alias")?.text.trim();
805
- const imported = path9 ? rustLeafName(path9) : null;
805
+ const imported = path10 ? rustLeafName(path10) : 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 path10 = node.namedChildren.find((child) => child?.type === "scoped_identifier");
827
+ const specifier = dottedToPath(path10?.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 = path10?.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;
@@ -1533,8 +1574,8 @@ function resolveCallTargetsFor(fileSet, file, call, symbolsByName, symbolsByFile
1533
1574
  if (!qualified) {
1534
1575
  const locals = named.filter((s) => s.file === file.path && lexicallyVisible(s, call));
1535
1576
  if (locals.length > 0) {
1536
- const unique = locals.length === 1;
1537
- const owned = unique && isStructuralOwner(call, locals[0]);
1577
+ const unique2 = locals.length === 1;
1578
+ const owned = unique2 && isStructuralOwner(call, locals[0]);
1538
1579
  const provenance = owned && call.fromId !== null ? "structural-owner" : "local-unique";
1539
1580
  return toEdges(locals, call, file.path, provenance, owned ? "exact" : "name-only", maxCandidates);
1540
1581
  }
@@ -1618,10 +1659,10 @@ function resolveCallTargetsFor(fileSet, file, call, symbolsByName, symbolsByFile
1618
1659
  return [];
1619
1660
  }
1620
1661
  function toEdges(candidates, call, filePath, provenance, resolution, maxCandidates) {
1621
- const unique = dedupeSymbols(candidates);
1622
- const count = unique.length;
1662
+ const unique2 = dedupeSymbols(candidates);
1663
+ const count = unique2.length;
1623
1664
  const cap = maxCandidates > 0 ? Math.floor(maxCandidates) : 0;
1624
- return unique.slice(0, cap).map((target) => ({
1665
+ return unique2.slice(0, cap).map((target) => ({
1625
1666
  sourceId: call.fromId,
1626
1667
  targetId: target.id,
1627
1668
  callSite: { file: filePath, line: call.line },
@@ -1701,7 +1742,7 @@ function isSubsequence(query, name2) {
1701
1742
  }
1702
1743
  return false;
1703
1744
  }
1704
- function searchSymbols(index, filter, limit = 50) {
1745
+ function searchSymbols(index, filter, limit = 50, refs = callerCounts(index)) {
1705
1746
  const q = (filter.query ?? "").trim();
1706
1747
  const filePat = filter.file?.trim().toLowerCase();
1707
1748
  const hits = [];
@@ -1715,7 +1756,6 @@ function searchSymbols(index, filter, limit = 50) {
1715
1756
  hits.push({ ...sym, score });
1716
1757
  }
1717
1758
  }
1718
- const refs = callerCounts(index);
1719
1759
  hits.sort((a, b) => {
1720
1760
  const ab = Number(b.exported) - Number(a.exported) || b.score - a.score || (refs.get(b.name) ?? 0) - (refs.get(a.name) ?? 0) || a.name.localeCompare(b.name) || a.file.localeCompare(b.file);
1721
1761
  return ab;
@@ -1795,13 +1835,13 @@ function findOrphanModules(index) {
1795
1835
  index.files.filter((file) => file.symbols.length > 0).map((file) => file.path)
1796
1836
  );
1797
1837
  return nodes.filter(
1798
- (path9) => withSymbols.has(path9) && (incoming.get(path9) ?? 0) === 0 && (edges.get(path9)?.size ?? 0) === 0 && !ENTRY_RE.test(path9) && !TEST_RE.test(path9)
1838
+ (path10) => withSymbols.has(path10) && (incoming.get(path10) ?? 0) === 0 && (edges.get(path10)?.size ?? 0) === 0 && !ENTRY_RE.test(path10) && !TEST_RE.test(path10)
1799
1839
  );
1800
1840
  }
1801
1841
 
1802
1842
  // src/git-diff.ts
1803
1843
  import { execFile } from "child_process";
1804
- import { readFile as readFile4 } from "fs/promises";
1844
+ import { readFile as readFile5 } from "fs/promises";
1805
1845
  import path6 from "path";
1806
1846
  var HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
1807
1847
  var HEADER_PATH_PREFIX_RE = /^[ab]\//;
@@ -2014,7 +2054,7 @@ async function readWorkingTreeDiff(root, baseRef) {
2014
2054
  const normalized = rawPath.replace(/\\/g, "/");
2015
2055
  if (normalized === ".dsh-code-index" || normalized.startsWith(".dsh-code-index/")) continue;
2016
2056
  try {
2017
- const content = await readFile4(path6.resolve(root, normalized), "utf8");
2057
+ const content = await readFile5(path6.resolve(root, normalized), "utf8");
2018
2058
  const lines = content.length === 0 ? 0 : content.split(/\r?\n/).length - (content.endsWith("\n") ? 1 : 0);
2019
2059
  untrackedFiles.push({ path: normalized, content, lines });
2020
2060
  } catch {
@@ -2715,25 +2755,444 @@ function renderChangeContext(result, maxChars = 12e3) {
2715
2755
  return out;
2716
2756
  }
2717
2757
 
2758
+ // src/context.ts
2759
+ var TEST_PATH_RE2 = /(^|\/)(tests?|__tests__)(\/|$)|\.(?:spec|test)\.[cm]?[jt]sx?$|(^|\/)(?:test_[^/]+\.py|[^/]+_test\.(?:py|go))$/i;
2760
+ var FILE_TOKEN_RE = /[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+/g;
2761
+ var CODE_TOKEN_RE = /[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?/g;
2762
+ var STOP_WORDS = /* @__PURE__ */ new Set(
2763
+ [
2764
+ "a",
2765
+ "an",
2766
+ "and",
2767
+ "are",
2768
+ "be",
2769
+ "by",
2770
+ "does",
2771
+ "for",
2772
+ "from",
2773
+ "how",
2774
+ "in",
2775
+ "is",
2776
+ "it",
2777
+ "my",
2778
+ "of",
2779
+ "on",
2780
+ "or",
2781
+ "the",
2782
+ "this",
2783
+ "to",
2784
+ "what",
2785
+ "where",
2786
+ "why",
2787
+ "with"
2788
+ ]
2789
+ );
2790
+ var CHANGE_RE = /\b(fix|bug|broken|breaks?|change|changed|current|working\s+tree|regression|impact|modified|duplicate|fails?|failure)\b/i;
2791
+ var TEST_RE3 = /\b(test|tests|spec|specs|failing|assert|coverage)\b/i;
2792
+ var ARCH_RE = /\b(architecture|architectural|flow|loading|authentication|authorization|plugin|startup|overview|pipeline|lifecycle|design|works?)\b/i;
2793
+ var EXPLORE_RE = /\b(where|implement|add|feature|introduce|entry\s+point|similar|explore|understand|locate)\b/i;
2794
+ function unique(values, key) {
2795
+ const seen = /* @__PURE__ */ new Set();
2796
+ const out = [];
2797
+ for (const value of values) {
2798
+ const id = key(value);
2799
+ if (seen.has(id)) continue;
2800
+ seen.add(id);
2801
+ out.push(value);
2802
+ }
2803
+ return out;
2804
+ }
2805
+ function clamp2(value, min, max, fallback) {
2806
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
2807
+ return Math.min(max, Math.max(min, Math.floor(value)));
2808
+ }
2809
+ function wordBoundary(name2) {
2810
+ return new RegExp(`(^|[^A-Za-z0-9_$])${name2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[^A-Za-z0-9_$])`);
2811
+ }
2812
+ function symbolLabel2(symbol) {
2813
+ return `${symbol.kind} ${symbol.signature || symbol.name} \u2014 ${symbol.file}:${symbol.line}`;
2814
+ }
2815
+ function isTestFile(file) {
2816
+ return TEST_PATH_RE2.test(file);
2817
+ }
2818
+ function fileMentionMatches(file, mentions) {
2819
+ const lower = file.toLowerCase();
2820
+ return mentions.some((mention) => {
2821
+ const normalized = mention.replace(/\\/g, "/").toLowerCase();
2822
+ return lower === normalized || lower.endsWith(`/${normalized}`) || lower.includes(normalized);
2823
+ });
2824
+ }
2825
+ function routeTask(task, index) {
2826
+ const text = task.trim();
2827
+ 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])));
2828
+ const exactSymbolNames = index ? unique(
2829
+ index.files.flatMap((file) => file.symbols.map((symbol) => symbol.name)),
2830
+ (name2) => name2
2831
+ ).filter((name2) => wordBoundary(name2).test(text)).sort((a, b) => b.length - a.length || a.localeCompare(b)) : [];
2832
+ const codeTokens = unique(
2833
+ (text.match(CODE_TOKEN_RE) ?? []).map((token) => token.replace(/^[^A-Za-z_$]+|[^A-Za-z0-9_$]+$/g, "")).filter((token) => token.length >= 3 && !STOP_WORDS.has(token.toLowerCase())),
2834
+ (token) => token.toLowerCase()
2835
+ );
2836
+ const queries = unique(
2837
+ [...exactSymbolNames, ...codeTokens.filter((token) => !exactSymbolNames.includes(token))],
2838
+ (value) => value.toLowerCase()
2839
+ ).slice(0, 12);
2840
+ 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";
2841
+ const includeChangeContext = kind === "change" || kind === "test";
2842
+ const includeRelationships = kind === "change" || kind === "test" || kind === "symbol";
2843
+ const sources = /* @__PURE__ */ new Set();
2844
+ if (kind === "change" || kind === "test") sources.add("change context");
2845
+ if (kind === "architecture" || kind === "exploration" || kind === "ambiguous") {
2846
+ sources.add("repo map");
2847
+ }
2848
+ if (queries.length > 0) sources.add("ranked search");
2849
+ if (includeRelationships) sources.add("call graph");
2850
+ if (mentionedFiles.length > 0) sources.add("file relevance");
2851
+ if (sources.size === 0) sources.add("repo map");
2852
+ return {
2853
+ kind,
2854
+ queries,
2855
+ mentionedFiles,
2856
+ exactSymbolNames,
2857
+ sources: [...sources],
2858
+ includeChangeContext,
2859
+ includeRelationships
2860
+ };
2861
+ }
2862
+ function routeScore(route, hit) {
2863
+ let score = hit.score * 30;
2864
+ if (route.exactSymbolNames.includes(hit.name)) score += 100;
2865
+ if (fileMentionMatches(hit.file, route.mentionedFiles)) score += 30;
2866
+ if (isTestFile(hit.file) && route.kind !== "test") score -= 10;
2867
+ return score;
2868
+ }
2869
+ function gatherPrimarySymbols(index, route, maxSymbols, changedIds, changedFiles, refs) {
2870
+ const hits = [];
2871
+ for (const query of route.queries) {
2872
+ hits.push(...searchSymbols(index, { query }, Math.max(maxSymbols * 3, 20), refs));
2873
+ }
2874
+ const byId = /* @__PURE__ */ new Map();
2875
+ for (const hit of hits) {
2876
+ const exact = route.exactSymbolNames.includes(hit.name);
2877
+ let score = routeScore(route, hit);
2878
+ if (changedIds.has(hit.id)) score += 80;
2879
+ else if (changedFiles.has(hit.file)) score += 55;
2880
+ if (route.kind === "test" && isTestFile(hit.file)) score += 35;
2881
+ const candidate = { symbol: hit, score, provenance: exact ? "exact" : "lexical" };
2882
+ const previous = byId.get(hit.id);
2883
+ if (!previous || candidate.score > previous.score) byId.set(hit.id, candidate);
2884
+ }
2885
+ if (route.includeChangeContext) {
2886
+ for (const file of index.files) {
2887
+ if (!changedFiles.has(file.path)) continue;
2888
+ for (const symbol of file.symbols) {
2889
+ if (byId.has(symbol.id)) continue;
2890
+ byId.set(symbol.id, {
2891
+ symbol,
2892
+ score: 70,
2893
+ provenance: "lexical"
2894
+ });
2895
+ }
2896
+ }
2897
+ }
2898
+ return [...byId.values()].sort(
2899
+ (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)
2900
+ ).slice(0, maxSymbols);
2901
+ }
2902
+ function edgeText(graph, edge, direction) {
2903
+ const source = edge.sourceId ? graph.symbolsById.get(edge.sourceId) : void 0;
2904
+ const target = graph.symbolsById.get(edge.targetId);
2905
+ const sourceText = source ? `${source.name} (${source.file}:${source.line})` : "module";
2906
+ const targetText = target ? `${target.name} (${target.file}:${target.line})` : edge.name;
2907
+ const arrow = direction === "caller" ? `${sourceText} \u2192 ${targetText}` : `${sourceText} \u2192 ${targetText}`;
2908
+ return `${arrow} at ${edge.callSite.file}:${edge.callSite.line} [${edge.resolution}; ${edge.provenance}]`;
2909
+ }
2910
+ function collectRelationships(graph, primary) {
2911
+ const rows = [];
2912
+ const seen = /* @__PURE__ */ new Set();
2913
+ const add = (edge, direction, priority) => {
2914
+ const key = `${edge.sourceId ?? "module"}:${edge.targetId}:${edge.callSite.file}:${edge.callSite.line}`;
2915
+ if (seen.has(key)) return;
2916
+ seen.add(key);
2917
+ rows.push({
2918
+ key,
2919
+ text: edgeText(graph, edge, direction),
2920
+ resolution: edge.resolution,
2921
+ priority
2922
+ });
2923
+ };
2924
+ for (const candidate of primary) {
2925
+ for (const edge of graph.incoming.get(candidate.symbol.id) ?? []) add(edge, "caller", 100);
2926
+ for (const edge of graph.outgoing.get(candidate.symbol.id) ?? []) add(edge, "callee", 90);
2927
+ }
2928
+ return rows.sort(
2929
+ (a, b) => b.priority - a.priority || a.resolution.localeCompare(b.resolution) || a.text.localeCompare(b.text)
2930
+ );
2931
+ }
2932
+ function addFile(files, path10, score, reason) {
2933
+ const previous = files.get(path10);
2934
+ if (!previous || score > previous.score) files.set(path10, { path: path10, score, reason });
2935
+ }
2936
+ function gatherFiles(index, route, primary, change, maxFiles) {
2937
+ const files = /* @__PURE__ */ new Map();
2938
+ for (const file of index.files) {
2939
+ const base = Math.min(scoreFile(file), 8);
2940
+ if (route.kind === "architecture" || route.kind === "exploration" || route.kind === "ambiguous") {
2941
+ addFile(files, file.path, base, "structural importance");
2942
+ }
2943
+ if (fileMentionMatches(file.path, route.mentionedFiles)) addFile(files, file.path, 120, "task-mentioned file");
2944
+ }
2945
+ for (const candidate of primary) {
2946
+ addFile(files, candidate.symbol.file, candidate.score + 45, "primary symbol");
2947
+ }
2948
+ if (change) {
2949
+ for (const changed of change.changed) addFile(files, changed.symbol.file, 115, "current change");
2950
+ for (const dependent of change.importDependents) addFile(files, dependent.file, 65, "import dependent");
2951
+ for (const test of change.tests) addFile(files, test.file, 75, "likely affected test");
2952
+ for (const pathRow of change.paths) {
2953
+ addFile(files, pathRow.entry.file, 60, "entry path");
2954
+ addFile(files, pathRow.changed.file, 90, "changed path");
2955
+ }
2956
+ }
2957
+ for (const entry of rankRepoMap(index, { topFiles: maxFiles * 2, symbolsPerFile: 8 })) {
2958
+ addFile(files, entry.path, Math.min(35, entry.score + 10), "repo map");
2959
+ }
2960
+ return [...files.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, maxFiles);
2961
+ }
2962
+ function confidenceOf(primary, relationships, change) {
2963
+ const out = { exact: 0, "import-scoped": 0, "name-only": 0 };
2964
+ for (const symbol of primary) if (symbol.provenance === "exact") out.exact++;
2965
+ for (const relation of relationships) out[relation.resolution]++;
2966
+ if (change) {
2967
+ for (const row of [...change.directCallers, ...change.impact]) out[row.resolution]++;
2968
+ for (const dependent of change.importDependents) out[dependent.resolution]++;
2969
+ for (const test of change.tests) out[test.resolution]++;
2970
+ }
2971
+ return out;
2972
+ }
2973
+ function renderRows(result, budgetChars) {
2974
+ const lines = [
2975
+ `Task context \u2014 ${result.route.kind}`,
2976
+ `Task: ${result.task}`,
2977
+ `Sources: ${result.route.sources.join(", ")}`,
2978
+ "",
2979
+ "Primary symbols:"
2980
+ ];
2981
+ if (result.primarySymbols.length === 0) lines.push("- (none found)");
2982
+ const optional = [];
2983
+ for (const candidate2 of result.primarySymbols) {
2984
+ optional.push({
2985
+ priority: 1e3 + candidate2.score,
2986
+ key: `symbol:${candidate2.symbol.id}`,
2987
+ section: "primary",
2988
+ line: `- [${candidate2.provenance}] ${symbolLabel2(candidate2.symbol)}`
2989
+ });
2990
+ }
2991
+ optional.push({ priority: 700, key: "files-header", section: "files", line: "\nRelevant files:" });
2992
+ for (const file of result.relevantFiles) {
2993
+ optional.push({ priority: 500 + file.score, key: `file:${file.path}`, section: "files", line: `- ${file.path} (${file.reason})` });
2994
+ }
2995
+ optional.push({ priority: 400, key: "relationships-header", section: "relationships", line: "\nRelationships:" });
2996
+ for (const relation of result.relationships) {
2997
+ optional.push({ priority: relation.priority, key: `relation:${relation.key}`, section: "relationships", line: `- ${relation.text}` });
2998
+ }
2999
+ if (result.changeContext) {
3000
+ optional.push({ priority: 850, key: "changes-header", section: "changes", line: "\nCurrent changes:" });
3001
+ for (const changed of result.changeContext.changed) {
3002
+ optional.push({
3003
+ priority: 840,
3004
+ key: `changed:${changed.side}:${changed.symbol.id}`,
3005
+ section: "changes",
3006
+ line: `- ${changed.change} ${symbolLabel2(changed.symbol)} [${changed.resolution}]`
3007
+ });
3008
+ }
3009
+ if (result.changeContext.unmapped.length > 0) {
3010
+ optional.push({
3011
+ priority: 830,
3012
+ key: "changes-unmapped",
3013
+ section: "changes",
3014
+ line: `- unmapped: ${result.changeContext.unmapped.map((entry) => entry.file).join(", ")}`
3015
+ });
3016
+ }
3017
+ }
3018
+ if (result.tests.length > 0) {
3019
+ optional.push({ priority: 350, key: "tests-header", section: "tests", line: "\nLikely affected tests:" });
3020
+ for (const test of result.tests) optional.push({ priority: 340, key: `test:${test}`, section: "tests", line: `- ${test}` });
3021
+ }
3022
+ if (result.warnings.length > 0) {
3023
+ optional.push({ priority: 200, key: "warnings-header", section: "warnings", line: "\nWarnings:" });
3024
+ for (const warning of result.warnings) optional.push({ priority: 190, key: `warning:${warning}`, section: "warnings", line: `- ${warning}` });
3025
+ }
3026
+ const ordered = optional.map((item, index) => ({ ...item, index })).sort((a, b) => b.priority - a.priority || a.index - b.index || a.key.localeCompare(b.key));
3027
+ const footerReserve = 100;
3028
+ let truncated = false;
3029
+ const seen = /* @__PURE__ */ new Set();
3030
+ for (const item of ordered) {
3031
+ if (seen.has(item.key)) continue;
3032
+ const next = lines.join("\n") + `
3033
+ ${item.line}`;
3034
+ if (next.length + footerReserve > budgetChars) {
3035
+ truncated = true;
3036
+ continue;
3037
+ }
3038
+ seen.add(item.key);
3039
+ lines.push(item.line);
3040
+ }
3041
+ lines.push(
3042
+ "",
3043
+ `Confidence: exact ${result.confidence.exact}; import-scoped ${result.confidence["import-scoped"]}; name-only ${result.confidence["name-only"]}`
3044
+ );
3045
+ const body = lines.join("\n");
3046
+ const withFooter = (bodyText, wasTruncated) => {
3047
+ let usedChars = bodyText.length + 1;
3048
+ for (let iteration = 0; iteration < 10; iteration++) {
3049
+ const footer2 = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3050
+ const text2 = `${bodyText}
3051
+ ${footer2}`;
3052
+ const nextUsedChars = text2.length;
3053
+ if (nextUsedChars === usedChars) return { text: text2, usedChars: nextUsedChars };
3054
+ usedChars = nextUsedChars;
3055
+ }
3056
+ const footer = `Budget: ${usedChars} / ${budgetChars} chars${wasTruncated ? " (trimmed low-priority context)" : ""}`;
3057
+ const text = `${bodyText}
3058
+ ${footer}`;
3059
+ return { text, usedChars: text.length };
3060
+ };
3061
+ const complete = withFooter(body, truncated);
3062
+ if (complete.usedChars <= budgetChars) {
3063
+ return { text: complete.text, usedChars: complete.usedChars, truncated };
3064
+ }
3065
+ truncated = true;
3066
+ const candidate = (prefixLength) => {
3067
+ const prefix = `${body.slice(0, prefixLength).trimEnd()}\u2026`;
3068
+ return withFooter(prefix, true);
3069
+ };
3070
+ let low = 0;
3071
+ let high = body.length;
3072
+ let best = candidate(0);
3073
+ while (low <= high) {
3074
+ const middle = Math.floor((low + high) / 2);
3075
+ const current = candidate(middle);
3076
+ if (current.usedChars <= budgetChars) {
3077
+ best = current;
3078
+ low = middle + 1;
3079
+ } else {
3080
+ high = middle - 1;
3081
+ }
3082
+ }
3083
+ return { text: best.text, usedChars: best.usedChars, truncated };
3084
+ }
3085
+ async function buildTaskContext(index, task, options = {}) {
3086
+ const budgetChars = clamp2(options.budgetChars, 300, 2e4, 5e3);
3087
+ const maxFiles = clamp2(options.maxFiles, 1, 50, 12);
3088
+ const maxSymbols = clamp2(options.maxSymbols, 1, 50, 10);
3089
+ const route = routeTask(task, index);
3090
+ let changeContext = null;
3091
+ const warnings = [];
3092
+ if (route.includeChangeContext) {
3093
+ try {
3094
+ changeContext = await buildChangeContext(index, {
3095
+ kind: "git",
3096
+ root: index.root,
3097
+ baseRef: "HEAD"
3098
+ });
3099
+ warnings.push(...changeContext.warnings);
3100
+ } catch (error) {
3101
+ warnings.push(`change context unavailable: ${error.message ?? String(error)}`);
3102
+ }
3103
+ }
3104
+ const changedIds = new Set((changeContext?.changed ?? []).map((entry) => entry.symbol.id));
3105
+ const changedFiles = new Set((changeContext?.changed ?? []).map((entry) => entry.symbol.file));
3106
+ const primarySymbols = gatherPrimarySymbols(
3107
+ index,
3108
+ route,
3109
+ maxSymbols,
3110
+ changedIds,
3111
+ changedFiles,
3112
+ callerCounts(index)
3113
+ );
3114
+ const graph = route.includeRelationships ? buildReferenceGraph(index) : null;
3115
+ const relationships = graph ? collectRelationships(graph, primarySymbols) : [];
3116
+ if (changeContext) {
3117
+ const existing = new Set(relationships.map((row) => row.key));
3118
+ for (const row of changeContext.directCallers) {
3119
+ const key = `change:${row.symbol.id}:${row.via.callSite.file}:${row.via.callSite.line}`;
3120
+ if (existing.has(key)) continue;
3121
+ existing.add(key);
3122
+ relationships.push({
3123
+ key,
3124
+ 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}]`,
3125
+ resolution: row.resolution,
3126
+ priority: 110
3127
+ });
3128
+ }
3129
+ relationships.sort((a, b) => b.priority - a.priority || a.text.localeCompare(b.text));
3130
+ }
3131
+ const relevantFiles = gatherFiles(index, route, primarySymbols, changeContext, maxFiles);
3132
+ const tests = unique(changeContext?.tests.map((test) => test.file) ?? [], (file) => file);
3133
+ const confidence = confidenceOf(primarySymbols, relationships, changeContext);
3134
+ const partial = {
3135
+ root: index.root,
3136
+ task,
3137
+ route,
3138
+ primarySymbols,
3139
+ relevantFiles,
3140
+ relationships,
3141
+ changeContext,
3142
+ tests,
3143
+ confidence,
3144
+ warnings
3145
+ };
3146
+ const rendered = renderRows(partial, budgetChars);
3147
+ return {
3148
+ ...partial,
3149
+ budget: {
3150
+ usedChars: rendered.usedChars,
3151
+ budgetChars,
3152
+ truncated: rendered.truncated
3153
+ }
3154
+ };
3155
+ }
3156
+ function renderTaskContext(result) {
3157
+ const rendered = renderRows(result, result.budget.budgetChars);
3158
+ return rendered.text;
3159
+ }
3160
+
2718
3161
  // src/tools.ts
2719
3162
  import { defineTool } from "@deepseek-ai/dsh-tools";
2720
- import path8 from "path";
3163
+ import path9 from "path";
2721
3164
 
2722
3165
  // src/config.ts
3166
+ import Schema from "@deepseek-ai/schemastery";
3167
+ var Config = Schema.object({
3168
+ excludeDirs: Schema.array(Schema.string()).default([]).volatile(),
3169
+ mapTopFiles: Schema.number().default(24).volatile(),
3170
+ mapMaxChars: Schema.number().default(3200).volatile(),
3171
+ mapTtlMs: Schema.number().default(6e4).volatile(),
3172
+ autoInject: Schema.boolean().default(true).volatile(),
3173
+ codeHealth: Schema.boolean().default(false).volatile(),
3174
+ toolSurface: Schema.string().default("full").volatile(),
3175
+ externalWatch: Schema.boolean().default(true).volatile(),
3176
+ watchDebounceMs: Schema.number().default(120).volatile()
3177
+ });
2723
3178
  var DEFAULTS = {
2724
3179
  excludeDirs: [],
2725
3180
  mapTopFiles: 24,
2726
3181
  mapMaxChars: 3200,
2727
3182
  mapTtlMs: 6e4,
2728
3183
  autoInject: true,
2729
- codeHealth: false
3184
+ codeHealth: false,
3185
+ toolSurface: "full",
3186
+ externalWatch: true,
3187
+ watchDebounceMs: 120
2730
3188
  };
2731
3189
  var state = { current: { ...DEFAULTS } };
2732
3190
  function applyConfig(partial) {
3191
+ const resolved = partial ? Object.fromEntries(Object.entries(partial).map(([key, value]) => [key, unwrapVolatile(value)])) : void 0;
2733
3192
  state.current = {
2734
3193
  ...DEFAULTS,
2735
- ...partial ?? {},
2736
- excludeDirs: [...DEFAULTS.excludeDirs, ...partial?.excludeDirs ?? []]
3194
+ ...resolved ?? {},
3195
+ excludeDirs: [...DEFAULTS.excludeDirs, ...resolved?.excludeDirs ?? []]
2737
3196
  };
2738
3197
  if (!Number.isFinite(state.current.mapTopFiles) || state.current.mapTopFiles < 1) {
2739
3198
  state.current.mapTopFiles = DEFAULTS.mapTopFiles;
@@ -2744,6 +3203,19 @@ function applyConfig(partial) {
2744
3203
  if (!Number.isFinite(state.current.mapTtlMs) || state.current.mapTtlMs < 1e3) {
2745
3204
  state.current.mapTtlMs = DEFAULTS.mapTtlMs;
2746
3205
  }
3206
+ if (state.current.toolSurface !== "full" && state.current.toolSurface !== "compact") {
3207
+ state.current.toolSurface = DEFAULTS.toolSurface;
3208
+ }
3209
+ if (typeof state.current.externalWatch !== "boolean") state.current.externalWatch = DEFAULTS.externalWatch;
3210
+ if (!Number.isFinite(state.current.watchDebounceMs) || state.current.watchDebounceMs < 20) {
3211
+ state.current.watchDebounceMs = DEFAULTS.watchDebounceMs;
3212
+ }
3213
+ }
3214
+ function unwrapVolatile(value) {
3215
+ if (typeof value !== "object" || value === null || !("get" in value) || typeof value.get !== "function") {
3216
+ return value;
3217
+ }
3218
+ return value.get();
2747
3219
  }
2748
3220
  function getConfig() {
2749
3221
  return state.current;
@@ -2752,67 +3224,192 @@ function indexOptions() {
2752
3224
  return { excludeDirs: state.current.excludeDirs };
2753
3225
  }
2754
3226
 
2755
- // src/tools.ts
2756
- function createIndexCache(load, ttlMs = 6e4, now = Date.now) {
2757
- const completed = /* @__PURE__ */ new Map();
2758
- const inFlight = /* @__PURE__ */ new Map();
2759
- let epoch = 0;
2760
- function startLoad(key, root, previous) {
2761
- const loadEpoch = epoch;
2762
- const begin = previous ? previous.catch(() => void 0).then(() => load(root)) : load(root);
2763
- const promise = begin.then((index) => {
2764
- if (loadEpoch === epoch) {
2765
- completed.delete(key);
2766
- completed.set(key, { index, at: now(), epoch: loadEpoch });
2767
- while (completed.size > 16) completed.delete(completed.keys().next().value);
3227
+ // src/repo-context.ts
3228
+ import { realpath } from "fs/promises";
3229
+ import path8 from "path";
3230
+ import chokidar from "chokidar";
3231
+ function ignoredRelativePath(rel, options) {
3232
+ const components = rel.split(/[\\/]+/).filter(Boolean);
3233
+ const excluded = /* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_DIRS, ...options.excludeDirs ?? []]);
3234
+ return components.some((component) => excluded.has(component));
3235
+ }
3236
+ async function canonicalRepoRoot(root) {
3237
+ const resolved = await realpath(path8.resolve(root));
3238
+ return path8.normalize(resolved);
3239
+ }
3240
+ var RepoContextManager = class {
3241
+ contexts = /* @__PURE__ */ new Map();
3242
+ options;
3243
+ disposed = false;
3244
+ constructor(options) {
3245
+ this.options = {
3246
+ indexOptions: options.indexOptions,
3247
+ watch: options.watch ?? true,
3248
+ debounceMs: Math.max(20, options.debounceMs ?? 120),
3249
+ maxContexts: Math.max(1, options.maxContexts ?? 4),
3250
+ onRefresh: options.onRefresh
3251
+ };
3252
+ }
3253
+ async get(root, force = false) {
3254
+ if (this.disposed) throw new Error("repo context manager is disposed");
3255
+ const canonical = await canonicalRepoRoot(root);
3256
+ const key = cacheKeyForRoot(canonical);
3257
+ let context = this.contexts.get(key);
3258
+ if (!context) {
3259
+ context = {
3260
+ root: canonical,
3261
+ index: null,
3262
+ lastRefreshAt: 0,
3263
+ dirtyFiles: /* @__PURE__ */ new Set(),
3264
+ ignoreMatcherCache: /* @__PURE__ */ new Map(),
3265
+ watcher: null,
3266
+ debounceTimer: null,
3267
+ refresh: null,
3268
+ disposed: false,
3269
+ generation: 0,
3270
+ lastUsedAt: Date.now()
3271
+ };
3272
+ this.contexts.set(key, context);
3273
+ this.attachWatcher(context);
3274
+ this.evictInactive();
3275
+ } else {
3276
+ this.contexts.delete(key);
3277
+ this.contexts.set(key, context);
3278
+ context.lastUsedAt = Date.now();
3279
+ }
3280
+ let index = await this.refresh(context, force);
3281
+ while (!context.disposed && context.dirtyFiles.size > 0) {
3282
+ if (context.refresh) await context.refresh;
3283
+ else index = await this.refresh(context);
3284
+ }
3285
+ return index;
3286
+ }
3287
+ getContext(root) {
3288
+ const key = cacheKeyForRoot(path8.resolve(root));
3289
+ return this.contexts.get(key);
3290
+ }
3291
+ async dispose() {
3292
+ if (this.disposed) return;
3293
+ this.disposed = true;
3294
+ const contexts = [...this.contexts.values()];
3295
+ this.contexts.clear();
3296
+ await Promise.all(contexts.map((context) => this.disposeContext(context)));
3297
+ }
3298
+ reconfigure(options) {
3299
+ if (options.debounceMs !== void 0) this.options.debounceMs = Math.max(20, options.debounceMs);
3300
+ if (options.watch === void 0 || options.watch === this.options.watch) return;
3301
+ this.options.watch = options.watch;
3302
+ for (const context of this.contexts.values()) {
3303
+ if (options.watch) this.attachWatcher(context);
3304
+ else {
3305
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3306
+ context.debounceTimer = null;
3307
+ const watcher = context.watcher;
3308
+ context.watcher = null;
3309
+ void watcher?.close();
3310
+ }
3311
+ }
3312
+ }
3313
+ async refresh(context, force = false) {
3314
+ if (context.refresh) {
3315
+ const index = await context.refresh;
3316
+ return force ? this.refresh(context, true) : index;
3317
+ }
3318
+ const generation = context.generation;
3319
+ const dirty = force ? /* @__PURE__ */ new Set(["*"]) : new Set(context.dirtyFiles);
3320
+ context.dirtyFiles.clear();
3321
+ const promise = buildIndexWithCache(context.root, this.options.indexOptions(), void 0, dirty).then((index) => {
3322
+ if (!context.disposed && context.generation === generation) {
3323
+ context.index = index;
3324
+ context.lastRefreshAt = Date.now();
3325
+ this.options.onRefresh?.(context.root, index);
2768
3326
  }
2769
3327
  return index;
2770
3328
  }).finally(() => {
2771
- if (inFlight.get(key)?.promise === promise) inFlight.delete(key);
3329
+ if (context.refresh === promise) context.refresh = null;
3330
+ if (!context.disposed && context.dirtyFiles.size > 0) this.scheduleRefresh(context);
2772
3331
  });
2773
- inFlight.set(key, { promise, epoch: loadEpoch });
3332
+ context.refresh = promise;
2774
3333
  return promise;
2775
3334
  }
2776
- return {
2777
- get(root, force = false) {
2778
- const resolvedRoot = path8.resolve(root);
2779
- const key = cacheKeyForRoot(resolvedRoot);
2780
- const running = inFlight.get(key);
2781
- if (running) {
2782
- if (running.epoch === epoch && !force) return running.promise;
2783
- return startLoad(key, resolvedRoot, running.promise);
2784
- }
2785
- if (!force) {
2786
- const cached = completed.get(key);
2787
- if (cached && cached.epoch === epoch && now() - cached.at < ttlMs) {
2788
- completed.delete(key);
2789
- completed.set(key, cached);
2790
- return Promise.resolve(cached.index);
2791
- }
2792
- if (cached) completed.delete(key);
2793
- } else {
2794
- completed.delete(key);
2795
- }
2796
- return startLoad(key, resolvedRoot);
2797
- },
2798
- invalidate() {
2799
- epoch++;
2800
- completed.clear();
2801
- }
2802
- };
3335
+ attachWatcher(context) {
3336
+ if (!this.options.watch) return;
3337
+ const options = this.options.indexOptions();
3338
+ const ignored = (absolute) => {
3339
+ const rel = path8.relative(context.root, absolute);
3340
+ return rel === ".." || rel.startsWith(`..${path8.sep}`) || path8.isAbsolute(rel) || ignoredRelativePath(rel, options) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache);
3341
+ };
3342
+ const watcher = chokidar.watch(context.root, {
3343
+ ignored,
3344
+ ignoreInitial: true,
3345
+ persistent: true,
3346
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 20 },
3347
+ followSymlinks: false
3348
+ });
3349
+ context.watcher = watcher;
3350
+ watcher.on("all", (event, absolute) => {
3351
+ if (context.disposed || !["add", "change", "unlink"].includes(event)) return;
3352
+ const rel = path8.relative(context.root, absolute).split(path8.sep).join("/");
3353
+ if (!rel || ignoredRelativePath(rel, this.options.indexOptions()) || isGitIgnoredPath(context.root, rel, context.ignoreMatcherCache)) return;
3354
+ if (!SUPPORTED_EXTS.has(path8.extname(rel).toLowerCase())) return;
3355
+ context.dirtyFiles.add(rel);
3356
+ this.scheduleRefresh(context);
3357
+ });
3358
+ watcher.on("error", (error) => {
3359
+ if (!context.disposed) console.warn(`[dsh-code-index] watcher failed for ${context.root}: ${String(error)}`);
3360
+ });
3361
+ }
3362
+ scheduleRefresh(context) {
3363
+ if (context.disposed || this.disposed) return;
3364
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3365
+ context.debounceTimer = setTimeout(() => {
3366
+ context.debounceTimer = null;
3367
+ void this.refresh(context).catch((error) => {
3368
+ if (!context.disposed) console.warn(`[dsh-code-index] refresh failed for ${context.root}: ${String(error)}`);
3369
+ });
3370
+ }, this.options.debounceMs);
3371
+ }
3372
+ evictInactive() {
3373
+ while (this.contexts.size > this.options.maxContexts) {
3374
+ const oldest = this.contexts.entries().next().value;
3375
+ if (!oldest) return;
3376
+ this.contexts.delete(oldest[0]);
3377
+ void this.disposeContext(oldest[1]);
3378
+ }
3379
+ }
3380
+ async disposeContext(context) {
3381
+ context.disposed = true;
3382
+ context.generation++;
3383
+ if (context.debounceTimer) clearTimeout(context.debounceTimer);
3384
+ context.debounceTimer = null;
3385
+ const watcher = context.watcher;
3386
+ context.watcher = null;
3387
+ if (watcher) await watcher.close();
3388
+ await context.refresh?.catch(() => void 0);
3389
+ context.dirtyFiles.clear();
3390
+ context.ignoreMatcherCache.clear();
3391
+ context.index = null;
3392
+ }
3393
+ };
3394
+
3395
+ // src/tools.ts
3396
+ var activeRepoContexts = null;
3397
+ function activateRepoContextManager(manager) {
3398
+ activeRepoContexts = manager;
3399
+ }
3400
+ function clearRepoContextManager(manager) {
3401
+ if (activeRepoContexts === manager) activeRepoContexts = null;
2803
3402
  }
2804
- var indexCache = createIndexCache((root) => buildIndexWithCache(root, indexOptions()));
2805
3403
  function getIndex(root, force = false) {
2806
- return indexCache.get(root, force);
3404
+ return activeRepoContexts ? activeRepoContexts.get(root, force) : buildIndexWithCache(root, indexOptions(), void 0, force ? /* @__PURE__ */ new Set(["*"]) : void 0);
2807
3405
  }
2808
3406
  function invalidateIndexCache() {
2809
- indexCache.invalidate();
2810
3407
  }
2811
3408
  async function resolveRoot(arg, exec) {
2812
- const cwd = exec.agent?.session?.header?.cwd;
3409
+ const cwd = exec.agent?.session?.header?.cwd ?? exec.session?.header?.cwd ?? exec.cwd;
2813
3410
  const base = arg ?? cwd ?? process.cwd();
2814
3411
  const root = await findRepoRoot(base);
2815
- if (!root) throw new Error(`no git repository found from ${path8.resolve(base)}`);
3412
+ if (!root) throw new Error(`no git repository found from ${path9.resolve(base)}`);
2816
3413
  return root;
2817
3414
  }
2818
3415
  function hitsToSearchMeta(hits) {
@@ -2827,7 +3424,7 @@ function hitsToSearchMeta(hits) {
2827
3424
  }
2828
3425
  return {
2829
3426
  shape: "matches",
2830
- files: [...byFile.entries()].map(([path9, matches]) => ({ path: path9, matches })),
3427
+ files: [...byFile.entries()].map(([path10, matches]) => ({ path: path10, matches })),
2831
3428
  truncated: false,
2832
3429
  total: hits.length
2833
3430
  };
@@ -3155,6 +3752,60 @@ var tools = [
3155
3752
  }
3156
3753
  }
3157
3754
  }),
3755
+ defineTool({
3756
+ name: "code_context",
3757
+ 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.",
3758
+ parameters: {
3759
+ task: {
3760
+ type: "string",
3761
+ required: true,
3762
+ description: "The code task or question the agent is currently trying to complete."
3763
+ },
3764
+ budgetChars: {
3765
+ type: "number",
3766
+ description: "Hard output budget in characters (default 5000)."
3767
+ },
3768
+ maxFiles: {
3769
+ type: "number",
3770
+ description: "Maximum relevant files to include (default 12)."
3771
+ },
3772
+ maxSymbols: {
3773
+ type: "number",
3774
+ description: "Maximum primary symbols to include (default 10)."
3775
+ },
3776
+ repoRoot: {
3777
+ type: "string",
3778
+ description: "Optional absolute repo path; defaults to the session workspace root."
3779
+ }
3780
+ },
3781
+ output: {
3782
+ schema: { type: "string" },
3783
+ render: (_args, value) => [{ type: "text", text: value }]
3784
+ },
3785
+ presentCall: (args) => ({
3786
+ card: "generic",
3787
+ title: "Task context",
3788
+ kind: "search",
3789
+ rawInput: { task: args.task, budgetChars: args.budgetChars }
3790
+ }),
3791
+ async execute(args, exec) {
3792
+ try {
3793
+ if (typeof args.task !== "string" || args.task.trim().length === 0) {
3794
+ throw new Error("task must be a non-empty string");
3795
+ }
3796
+ const root = await resolveRoot(args.repoRoot, exec);
3797
+ const index = await getIndex(root);
3798
+ const result = await buildTaskContext(index, args.task, {
3799
+ budgetChars: args.budgetChars,
3800
+ maxFiles: args.maxFiles,
3801
+ maxSymbols: args.maxSymbols
3802
+ });
3803
+ return renderTaskContext(result);
3804
+ } catch (error) {
3805
+ return `code_context: ${error.message ?? String(error)}`;
3806
+ }
3807
+ }
3808
+ }),
3158
3809
  defineTool({
3159
3810
  name: "code_health",
3160
3811
  description: "Report structural health from the import graph: circular dependencies (import cycles) and orphan modules (symbol-bearing files that nothing imports and that import nothing, excluding entry points and tests).",
@@ -3196,24 +3847,32 @@ var tools = [
3196
3847
  ];
3197
3848
 
3198
3849
  // src/settings.ts
3199
- import Schema from "@deepseek-ai/schemastery";
3850
+ import Schema2 from "@deepseek-ai/schemastery";
3200
3851
  var SETTINGS_NAMESPACE = "code-index";
3201
- var settingsSchema = Schema.object({
3202
- excludeDirs: Schema.array(Schema.string()).default([]),
3203
- mapTopFiles: Schema.number().default(24),
3204
- mapMaxChars: Schema.number().default(3200),
3205
- mapTtlMs: Schema.number().default(6e4),
3206
- autoInject: Schema.boolean().default(true),
3207
- codeHealth: Schema.boolean().default(false)
3852
+ var settingsSchema = Schema2.object({
3853
+ excludeDirs: Schema2.array(Schema2.string()).default([]),
3854
+ mapTopFiles: Schema2.number().default(24),
3855
+ mapMaxChars: Schema2.number().default(3200),
3856
+ mapTtlMs: Schema2.number().default(6e4),
3857
+ autoInject: Schema2.boolean().default(true),
3858
+ codeHealth: Schema2.boolean().default(false),
3859
+ toolSurface: Schema2.string().default("full"),
3860
+ externalWatch: Schema2.boolean().default(true),
3861
+ watchDebounceMs: Schema2.number().default(120)
3208
3862
  });
3209
- function registerSettings(getService, pluginConfig, onResolved) {
3863
+ function registerSettings(getService, pluginConfig, onResolved, fiber) {
3210
3864
  const service = getService("settings");
3211
- if (!service) return;
3865
+ if (!service) return void 0;
3866
+ if (service.configure) {
3867
+ const dispose = service.configure({ auto: false }, fiber);
3868
+ onResolved(pluginConfig ?? {});
3869
+ return typeof dispose === "function" ? dispose : void 0;
3870
+ }
3212
3871
  const scope = service.register(SETTINGS_NAMESPACE, settingsSchema, {
3213
3872
  base: pluginConfig ?? {}
3214
3873
  });
3215
3874
  onResolved(scope.get());
3216
- scope.watch((next) => {
3875
+ return scope.watch((next) => {
3217
3876
  onResolved(next);
3218
3877
  });
3219
3878
  }
@@ -3226,29 +3885,51 @@ function apply(ctx, pluginConfig) {
3226
3885
  invalidateIndexCache();
3227
3886
  ctx.effect(() => {
3228
3887
  const disposers = [];
3888
+ const repoContexts = new RepoContextManager({
3889
+ indexOptions,
3890
+ watch: getConfig().externalWatch,
3891
+ debounceMs: getConfig().watchDebounceMs
3892
+ });
3893
+ activateRepoContextManager(repoContexts);
3894
+ const updateConfig = (next) => {
3895
+ applyConfig(next);
3896
+ repoContexts.reconfigure({ watch: getConfig().externalWatch, debounceMs: getConfig().watchDebounceMs });
3897
+ };
3229
3898
  console.log("[dsh-code-index] plugin loaded");
3230
3899
  if (ctx.inject) {
3231
- ctx.inject(["settings"], (settingsCtx) => {
3232
- registerSettings(
3900
+ const injected = ctx.inject(["settings"], (settingsCtx) => {
3901
+ const dispose = registerSettings(
3233
3902
  (name2) => settingsCtx.get(name2),
3234
3903
  pluginConfig,
3235
- (resolved) => applyConfig(resolved)
3904
+ (resolved) => updateConfig(resolved),
3905
+ settingsCtx.fiber
3236
3906
  );
3907
+ if (dispose) disposers.push(dispose);
3237
3908
  });
3909
+ if (typeof injected === "function") disposers.push(injected);
3238
3910
  }
3239
- for (const tool of tools) {
3911
+ const removeVolatileListener = ctx.on?.("loader/volatile-update", () => updateConfig(pluginConfig));
3912
+ if (removeVolatileListener) disposers.push(removeVolatileListener);
3913
+ const visibleTools = getConfig().toolSurface === "compact" ? tools.filter((tool) => ["code_index", "code_context", "code_health"].includes(tool.name)) : tools;
3914
+ for (const tool of visibleTools) {
3240
3915
  if (tool.name === "code_health" && !getConfig().codeHealth) continue;
3241
3916
  disposers.push(ctx.tools.register(tool));
3242
3917
  console.log(`[dsh-code-index] registered tool: ${tool.name}`);
3243
3918
  }
3244
- let cached = null;
3245
- async function warmMap() {
3919
+ const cachedMaps = /* @__PURE__ */ new Map();
3920
+ const saveMap = (key, value) => {
3921
+ cachedMaps.delete(key);
3922
+ cachedMaps.set(key, value);
3923
+ while (cachedMaps.size > 4) cachedMaps.delete(cachedMaps.keys().next().value);
3924
+ };
3925
+ async function warmMap(base) {
3246
3926
  const now = Date.now();
3247
3927
  const cfg = getConfig();
3928
+ const key = cacheKeyForRoot(base);
3248
3929
  try {
3249
- const root = await findRepoRoot(process.cwd());
3930
+ const root = await findRepoRoot(base);
3250
3931
  if (!root) {
3251
- cached = { root: "", at: now, text: "" };
3932
+ saveMap(key, { root: "", at: now, text: "" });
3252
3933
  return;
3253
3934
  }
3254
3935
  const index = await getIndex(root);
@@ -3257,27 +3938,34 @@ function apply(ctx, pluginConfig) {
3257
3938
  { maxChars: cfg.mapMaxChars }
3258
3939
  );
3259
3940
  const stats = symbolCount(index);
3260
- cached = {
3941
+ saveMap(key, {
3261
3942
  root: index.root,
3262
3943
  at: Date.now(),
3263
- text: text ? `${text}
3944
+ text: text ? `Project: ${index.root}
3945
+ ${text}
3264
3946
 
3265
3947
  (summary: ${index.files.length} files, ${stats} symbols)` : ""
3266
- };
3948
+ });
3267
3949
  } catch {
3268
- cached = { root: "", at: now, text: "" };
3950
+ saveMap(key, { root: "", at: now, text: "" });
3269
3951
  }
3270
3952
  }
3271
- void warmMap();
3272
3953
  if (getConfig().autoInject) {
3273
3954
  disposers.push(ctx.systemPrompt.section({
3274
3955
  name: "code-index:repo-map",
3275
3956
  order: 60,
3276
3957
  // before tool guidance (100–199), after persona (0)
3277
- text: () => {
3958
+ text: (rawContext) => {
3959
+ const context = rawContext;
3960
+ const base = context.agent?.session?.header?.cwd ?? context.session?.header?.cwd ?? process.cwd();
3961
+ const key = cacheKeyForRoot(base);
3278
3962
  const now = Date.now();
3279
- if (cached && now - cached.at < getConfig().mapTtlMs) return cached.text;
3280
- void warmMap();
3963
+ const cached = cachedMaps.get(key);
3964
+ if (cached && now - cached.at < getConfig().mapTtlMs) {
3965
+ saveMap(key, cached);
3966
+ return cached.text;
3967
+ }
3968
+ void warmMap(base);
3281
3969
  return cached?.text ?? "";
3282
3970
  }
3283
3971
  }));
@@ -3291,6 +3979,9 @@ function apply(ctx, pluginConfig) {
3291
3979
  errors.push(error);
3292
3980
  }
3293
3981
  }
3982
+ clearRepoContextManager(repoContexts);
3983
+ void repoContexts.dispose();
3984
+ cachedMaps.clear();
3294
3985
  console.log("[dsh-code-index] plugin unloaded");
3295
3986
  if (errors.length > 0) throw new AggregateError(errors, "failed to unload dsh-code-index");
3296
3987
  };
@@ -3298,6 +3989,7 @@ function apply(ctx, pluginConfig) {
3298
3989
  }
3299
3990
  export {
3300
3991
  CACHE_DIR_NAME,
3992
+ Config,
3301
3993
  DEFAULT_EXCLUDED_DIRS,
3302
3994
  SUPPORTED_EXTS,
3303
3995
  apply,
@@ -3306,6 +3998,7 @@ export {
3306
3998
  buildIndexWithCache,
3307
3999
  buildModuleGraph,
3308
4000
  buildSymbolTable,
4001
+ buildTaskContext,
3309
4002
  callerCounts,
3310
4003
  changeContextInputFromArgs,
3311
4004
  defaultCachePath,
@@ -3326,6 +4019,8 @@ export {
3326
4019
  renderChangeContext,
3327
4020
  renderHit,
3328
4021
  renderRepoMap,
4022
+ renderTaskContext,
4023
+ routeTask,
3329
4024
  saveIndex,
3330
4025
  scanRepo,
3331
4026
  scoreFile,