dsh-code-index 0.7.0 → 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;
@@ -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
+ (path10) => withSymbols.has(path10) && (incoming.get(path10) ?? 0) === 0 && (edges.get(path10)?.size ?? 0) === 0 && !ENTRY_RE.test(path10) && !TEST_RE.test(path10)
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]\//;
@@ -2013,7 +2054,7 @@ async function readWorkingTreeDiff(root, baseRef) {
2013
2054
  const normalized = rawPath.replace(/\\/g, "/");
2014
2055
  if (normalized === ".dsh-code-index" || normalized.startsWith(".dsh-code-index/")) continue;
2015
2056
  try {
2016
- const content = await readFile4(path6.resolve(root, normalized), "utf8");
2057
+ const content = await readFile5(path6.resolve(root, normalized), "utf8");
2017
2058
  const lines = content.length === 0 ? 0 : content.split(/\r?\n/).length - (content.endsWith("\n") ? 1 : 0);
2018
2059
  untrackedFiles.push({ path: normalized, content, lines });
2019
2060
  } catch {
@@ -2888,9 +2929,9 @@ function collectRelationships(graph, primary) {
2888
2929
  (a, b) => b.priority - a.priority || a.resolution.localeCompare(b.resolution) || a.text.localeCompare(b.text)
2889
2930
  );
2890
2931
  }
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 });
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 });
2894
2935
  }
2895
2936
  function gatherFiles(index, route, primary, change, maxFiles) {
2896
2937
  const files = /* @__PURE__ */ new Map();
@@ -3119,9 +3160,21 @@ function renderTaskContext(result) {
3119
3160
 
3120
3161
  // src/tools.ts
3121
3162
  import { defineTool } from "@deepseek-ai/dsh-tools";
3122
- import path8 from "path";
3163
+ import path9 from "path";
3123
3164
 
3124
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
+ });
3125
3178
  var DEFAULTS = {
3126
3179
  excludeDirs: [],
3127
3180
  mapTopFiles: 24,
@@ -3129,14 +3182,17 @@ var DEFAULTS = {
3129
3182
  mapTtlMs: 6e4,
3130
3183
  autoInject: true,
3131
3184
  codeHealth: false,
3132
- toolSurface: "full"
3185
+ toolSurface: "full",
3186
+ externalWatch: true,
3187
+ watchDebounceMs: 120
3133
3188
  };
3134
3189
  var state = { current: { ...DEFAULTS } };
3135
3190
  function applyConfig(partial) {
3191
+ const resolved = partial ? Object.fromEntries(Object.entries(partial).map(([key, value]) => [key, unwrapVolatile(value)])) : void 0;
3136
3192
  state.current = {
3137
3193
  ...DEFAULTS,
3138
- ...partial ?? {},
3139
- excludeDirs: [...DEFAULTS.excludeDirs, ...partial?.excludeDirs ?? []]
3194
+ ...resolved ?? {},
3195
+ excludeDirs: [...DEFAULTS.excludeDirs, ...resolved?.excludeDirs ?? []]
3140
3196
  };
3141
3197
  if (!Number.isFinite(state.current.mapTopFiles) || state.current.mapTopFiles < 1) {
3142
3198
  state.current.mapTopFiles = DEFAULTS.mapTopFiles;
@@ -3150,6 +3206,16 @@ function applyConfig(partial) {
3150
3206
  if (state.current.toolSurface !== "full" && state.current.toolSurface !== "compact") {
3151
3207
  state.current.toolSurface = DEFAULTS.toolSurface;
3152
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();
3153
3219
  }
3154
3220
  function getConfig() {
3155
3221
  return state.current;
@@ -3158,67 +3224,192 @@ function indexOptions() {
3158
3224
  return { excludeDirs: state.current.excludeDirs };
3159
3225
  }
3160
3226
 
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);
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);
3174
3326
  }
3175
3327
  return index;
3176
3328
  }).finally(() => {
3177
- 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);
3178
3331
  });
3179
- inFlight.set(key, { promise, epoch: loadEpoch });
3332
+ context.refresh = promise;
3180
3333
  return promise;
3181
3334
  }
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();
3207
- }
3208
- };
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;
3209
3402
  }
3210
- var indexCache = createIndexCache((root) => buildIndexWithCache(root, indexOptions()));
3211
3403
  function getIndex(root, force = false) {
3212
- return indexCache.get(root, force);
3404
+ return activeRepoContexts ? activeRepoContexts.get(root, force) : buildIndexWithCache(root, indexOptions(), void 0, force ? /* @__PURE__ */ new Set(["*"]) : void 0);
3213
3405
  }
3214
3406
  function invalidateIndexCache() {
3215
- indexCache.invalidate();
3216
3407
  }
3217
3408
  async function resolveRoot(arg, exec) {
3218
- const cwd = exec.agent?.session?.header?.cwd;
3409
+ const cwd = exec.agent?.session?.header?.cwd ?? exec.session?.header?.cwd ?? exec.cwd;
3219
3410
  const base = arg ?? cwd ?? process.cwd();
3220
3411
  const root = await findRepoRoot(base);
3221
- 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)}`);
3222
3413
  return root;
3223
3414
  }
3224
3415
  function hitsToSearchMeta(hits) {
@@ -3233,7 +3424,7 @@ function hitsToSearchMeta(hits) {
3233
3424
  }
3234
3425
  return {
3235
3426
  shape: "matches",
3236
- files: [...byFile.entries()].map(([path9, matches]) => ({ path: path9, matches })),
3427
+ files: [...byFile.entries()].map(([path10, matches]) => ({ path: path10, matches })),
3237
3428
  truncated: false,
3238
3429
  total: hits.length
3239
3430
  };
@@ -3656,25 +3847,32 @@ var tools = [
3656
3847
  ];
3657
3848
 
3658
3849
  // src/settings.ts
3659
- import Schema from "@deepseek-ai/schemastery";
3850
+ import Schema2 from "@deepseek-ai/schemastery";
3660
3851
  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")
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)
3669
3862
  });
3670
- function registerSettings(getService, pluginConfig, onResolved) {
3863
+ function registerSettings(getService, pluginConfig, onResolved, fiber) {
3671
3864
  const service = getService("settings");
3672
- 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
+ }
3673
3871
  const scope = service.register(SETTINGS_NAMESPACE, settingsSchema, {
3674
3872
  base: pluginConfig ?? {}
3675
3873
  });
3676
3874
  onResolved(scope.get());
3677
- scope.watch((next) => {
3875
+ return scope.watch((next) => {
3678
3876
  onResolved(next);
3679
3877
  });
3680
3878
  }
@@ -3687,30 +3885,51 @@ function apply(ctx, pluginConfig) {
3687
3885
  invalidateIndexCache();
3688
3886
  ctx.effect(() => {
3689
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
+ };
3690
3898
  console.log("[dsh-code-index] plugin loaded");
3691
3899
  if (ctx.inject) {
3692
- ctx.inject(["settings"], (settingsCtx) => {
3693
- registerSettings(
3900
+ const injected = ctx.inject(["settings"], (settingsCtx) => {
3901
+ const dispose = registerSettings(
3694
3902
  (name2) => settingsCtx.get(name2),
3695
3903
  pluginConfig,
3696
- (resolved) => applyConfig(resolved)
3904
+ (resolved) => updateConfig(resolved),
3905
+ settingsCtx.fiber
3697
3906
  );
3907
+ if (dispose) disposers.push(dispose);
3698
3908
  });
3909
+ if (typeof injected === "function") disposers.push(injected);
3699
3910
  }
3911
+ const removeVolatileListener = ctx.on?.("loader/volatile-update", () => updateConfig(pluginConfig));
3912
+ if (removeVolatileListener) disposers.push(removeVolatileListener);
3700
3913
  const visibleTools = getConfig().toolSurface === "compact" ? tools.filter((tool) => ["code_index", "code_context", "code_health"].includes(tool.name)) : tools;
3701
3914
  for (const tool of visibleTools) {
3702
3915
  if (tool.name === "code_health" && !getConfig().codeHealth) continue;
3703
3916
  disposers.push(ctx.tools.register(tool));
3704
3917
  console.log(`[dsh-code-index] registered tool: ${tool.name}`);
3705
3918
  }
3706
- let cached = null;
3707
- 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) {
3708
3926
  const now = Date.now();
3709
3927
  const cfg = getConfig();
3928
+ const key = cacheKeyForRoot(base);
3710
3929
  try {
3711
- const root = await findRepoRoot(process.cwd());
3930
+ const root = await findRepoRoot(base);
3712
3931
  if (!root) {
3713
- cached = { root: "", at: now, text: "" };
3932
+ saveMap(key, { root: "", at: now, text: "" });
3714
3933
  return;
3715
3934
  }
3716
3935
  const index = await getIndex(root);
@@ -3719,27 +3938,34 @@ function apply(ctx, pluginConfig) {
3719
3938
  { maxChars: cfg.mapMaxChars }
3720
3939
  );
3721
3940
  const stats = symbolCount(index);
3722
- cached = {
3941
+ saveMap(key, {
3723
3942
  root: index.root,
3724
3943
  at: Date.now(),
3725
- text: text ? `${text}
3944
+ text: text ? `Project: ${index.root}
3945
+ ${text}
3726
3946
 
3727
3947
  (summary: ${index.files.length} files, ${stats} symbols)` : ""
3728
- };
3948
+ });
3729
3949
  } catch {
3730
- cached = { root: "", at: now, text: "" };
3950
+ saveMap(key, { root: "", at: now, text: "" });
3731
3951
  }
3732
3952
  }
3733
- void warmMap();
3734
3953
  if (getConfig().autoInject) {
3735
3954
  disposers.push(ctx.systemPrompt.section({
3736
3955
  name: "code-index:repo-map",
3737
3956
  order: 60,
3738
3957
  // before tool guidance (100–199), after persona (0)
3739
- 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);
3740
3962
  const now = Date.now();
3741
- if (cached && now - cached.at < getConfig().mapTtlMs) return cached.text;
3742
- 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);
3743
3969
  return cached?.text ?? "";
3744
3970
  }
3745
3971
  }));
@@ -3753,6 +3979,9 @@ function apply(ctx, pluginConfig) {
3753
3979
  errors.push(error);
3754
3980
  }
3755
3981
  }
3982
+ clearRepoContextManager(repoContexts);
3983
+ void repoContexts.dispose();
3984
+ cachedMaps.clear();
3756
3985
  console.log("[dsh-code-index] plugin unloaded");
3757
3986
  if (errors.length > 0) throw new AggregateError(errors, "failed to unload dsh-code-index");
3758
3987
  };
@@ -3760,6 +3989,7 @@ function apply(ctx, pluginConfig) {
3760
3989
  }
3761
3990
  export {
3762
3991
  CACHE_DIR_NAME,
3992
+ Config,
3763
3993
  DEFAULT_EXCLUDED_DIRS,
3764
3994
  SUPPORTED_EXTS,
3765
3995
  apply,