dsh-vscode-mode 0.1.12 → 0.1.15

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/lib/index.js CHANGED
@@ -1,7 +1,11 @@
1
+ import { createRequire } from "node:module";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
5
+ import { existsSync } from "node:fs";
4
6
  import { createHash } from "node:crypto";
7
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
8
+ import z from "schemastery";
5
9
  //#region src/shared/rpc.ts
6
10
  /** webServer 精确路由。 */
7
11
  const RPC_PATH = "/edrv/rpc";
@@ -888,9 +892,6 @@ async function captureToolResult(ctx, registry, exec, result) {
888
892
  }
889
893
  record.batch = fileMaxBatch(bucket, path) + 1;
890
894
  bucket.set(exec.callId, record);
891
- const prior = [];
892
- for (const r of bucket.values()) if (r.path === path && r.callId !== exec.callId && !r.archived && (r.batch ?? 0) < record.batch) prior.push(r);
893
- if (prior.length) await archiveRecords(ctx, session, cwd, bucket, prior, prior.every(recordResolved) ? "已处理且被后续修改取代" : "被后续修改融合(内容已含其效果)");
894
895
  prune(bucket);
895
896
  await saveBucket(ctx, cwd, bucket, session);
896
897
  } catch (error) {
@@ -898,82 +899,600 @@ async function captureToolResult(ctx, registry, exec, result) {
898
899
  }
899
900
  }
900
901
  //#endregion
901
- //#region src/workspace.ts
902
- /** 文件清单缓存 TTL 与扫描上限(避免大工作区反复全量扫描)。 */
903
- const FILE_INDEX_TTL = 6e4;
904
- const SCAN_CAP = 6e3;
905
- /** cwd → { at, files }(随会话销毁清理)。 */
906
- const fileIndex = /* @__PURE__ */ new Map();
902
+ //#region src/search/query.ts
907
903
  /**
908
- * 扫描工作区文件清单(快速打开/搜索用):subprocess(Windows powershell / POSIX find),
909
- * 排除常见重目录,容量上限 SCAN_CAP;结果缓存(TTL FILE_INDEX_TTL)。失败返回 null。
910
- * @author ddj 2026年08月20号
904
+ * 规范化用户搜索文本。
905
+ * @author ddj 2026年08月24号
906
+ * @param raw 原始搜索文本
907
+ * @returns 用于比较和缓存的 query
908
+ */
909
+ function prepareQuery(raw) {
910
+ const value = String(raw ?? "").trim();
911
+ return {
912
+ raw: value,
913
+ text: value.replaceAll("\\", "/").toLocaleLowerCase("en-US")
914
+ };
915
+ }
916
+ /**
917
+ * 归一化路径分隔符并移除无意义前缀。
918
+ * @author ddj 2026年08月24号
919
+ * @param value 原始路径
920
+ * @returns 比较用路径
921
+ */
922
+ function pathText(value) {
923
+ return value.replaceAll("\\", "/").replace(/^\.\//, "");
924
+ }
925
+ /**
926
+ * 生成不区分大小写的路径 key。
927
+ * @author ddj 2026年08月24号
928
+ * @param value 原始路径
929
+ * @returns 去重 key
930
+ */
931
+ function pathKey(value) {
932
+ return pathText(value).toLocaleLowerCase("en-US");
933
+ }
934
+ /**
935
+ * 判断路径是否包含连续 query。
936
+ * @author ddj 2026年08月24号
937
+ * @param value 文件路径
938
+ * @param query 已规范化 query
939
+ * @returns 是否命中
940
+ */
941
+ function pathMatch(value, query) {
942
+ return pathText(value).toLocaleLowerCase("en-US").includes(query.text);
943
+ }
944
+ //#endregion
945
+ //#region src/search/ripgrep.ts
946
+ /**
947
+ * dsh-vscode-mode host — ripgrep 文件发现 provider。
948
+ * 通过独立 argv 调用打包 rg,避免先构建巨型全量文件数组。
949
+ * 作者 ddj 2026年08月24号
950
+ */
951
+ const STDOUT_CAP = 4 << 20;
952
+ const STDERR_CAP = 65536;
953
+ const GRACE_MS = 2e4;
954
+ const EXCLUDES = [
955
+ "node_modules",
956
+ ".git",
957
+ ".tmp",
958
+ ".cache",
959
+ "dist",
960
+ "build",
961
+ "vendor",
962
+ "coverage",
963
+ "__pycache__"
964
+ ];
965
+ /**
966
+ * 将 query 转成 rg 的字面 glob。
967
+ * @author ddj 2026年08月24号
968
+ * @param query 已规范化 query
969
+ * @returns 安全 glob
970
+ */
971
+ function queryGlob(query) {
972
+ return "**/*" + pathText(query).replace(/[\*?\[\]{}!]/g, (char) => "\\" + char) + "*";
973
+ }
974
+ /**
975
+ * 返回当前平台的 rg 可执行文件路径,不让可选依赖影响模块加载。
976
+ * @author ddj 2026年08月24号
977
+ * @returns rg 路径或 null
978
+ */
979
+ function ripgrepPath() {
980
+ try {
981
+ const ripgrep = createRequire(import.meta.url)("@vscode/ripgrep");
982
+ if (typeof ripgrep.rgPath !== "string" || !existsSync(ripgrep.rgPath)) return null;
983
+ return join(ripgrep.rgPath);
984
+ } catch (error) {
985
+ return null;
986
+ }
987
+ }
988
+ /**
989
+ * 解析会话搜索根目录,并转换到 subprocess 执行世界。
990
+ * @author ddj 2026年08月24号
911
991
  * @param ctx DSH 上下文
912
- * @param session 会话(取工作区根)
913
- * @param cwd 工作区目录
914
- * @returns 绝对路径清单;不可用(无 subprocess/fs)或扫描失败返回 null
992
+ * @param session 当前会话
993
+ * @returns subprocess 可访问的根目录
915
994
  */
916
- async function listWorkspaceFiles(ctx, session, cwd) {
917
- const cached = fileIndex.get(cwd);
918
- if (cached && Date.now() - cached.at < FILE_INDEX_TTL) return cached.files;
919
- const sub = ctx.get("subprocess");
995
+ async function searchRoot(ctx, session) {
920
996
  const fs = ctx.get("fs");
921
- if (!sub || !fs) return null;
997
+ if (!fs) throw new Error("缺少 fs");
922
998
  const policy = policyOf(ctx, session);
923
- let root;
999
+ const cwd = session?.header?.cwd;
1000
+ const rootTarget = await fs.resolve(policy?.workspaceRoot ?? ".", cwd ? { cwd } : {});
1001
+ return fs.processPath(rootTarget);
1002
+ }
1003
+ /**
1004
+ * 读取收集输出并解析为路径候选。
1005
+ * @author ddj 2026年08月24号
1006
+ * @param handle subprocess handle
1007
+ * @param maxResults 候选上限
1008
+ * @returns 路径与完整性状态
1009
+ */
1010
+ function parseOutput(handle, maxResults) {
1011
+ const reader = handle.collected?.stdout;
1012
+ if (!reader) return {
1013
+ files: [],
1014
+ truncated: false,
1015
+ complete: false
1016
+ };
1017
+ const output = reader.readFrom(0);
1018
+ const values = output.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1019
+ const unique = [...new Set(values)];
1020
+ const truncated = Boolean(output.lossy) || unique.length > maxResults;
1021
+ return {
1022
+ files: unique.slice(0, maxResults).map(pathText),
1023
+ truncated,
1024
+ complete: !truncated
1025
+ };
1026
+ }
1027
+ /**
1028
+ * 使用打包 ripgrep 搜索工作区文件。
1029
+ * @author ddj 2026年08月24号
1030
+ * @param input provider 输入
1031
+ * @returns 有界搜索结果
1032
+ */
1033
+ async function searchRipgrep(input) {
1034
+ const sub = input.ctx.get("subprocess");
1035
+ if (!sub) throw new Error("缺少 subprocess");
1036
+ const binary = ripgrepPath();
1037
+ if (!binary) throw new Error("ripgrep 不可用");
1038
+ const root = input.root ?? await searchRoot(input.ctx, input.session);
1039
+ const argv = [
1040
+ binary,
1041
+ "--no-config",
1042
+ "--files",
1043
+ "--hidden",
1044
+ "--no-ignore",
1045
+ "--glob-case-insensitive",
1046
+ "--glob",
1047
+ queryGlob(prepareQuery(input.query).text)
1048
+ ];
1049
+ for (const excluded of EXCLUDES) argv.push("--glob", "!**/" + excluded + "/**");
1050
+ argv.push("--", root);
1051
+ const handle = sub.spawn({
1052
+ argv,
1053
+ cwd: root,
1054
+ stdio: {
1055
+ stdin: "ignore",
1056
+ stdout: { maxBytes: STDOUT_CAP },
1057
+ stderr: { maxBytes: STDERR_CAP }
1058
+ },
1059
+ graceMs: GRACE_MS,
1060
+ signal: input.signal
1061
+ });
1062
+ let outcome;
924
1063
  try {
925
- const rootTarget = await fs.resolve(policy?.workspaceRoot ?? ".", {});
926
- root = fs.processPath(rootTarget);
1064
+ outcome = await handle.done;
927
1065
  } catch (error) {
928
- root = cwd;
1066
+ throw new Error("ripgrep 启动失败:" + String(error));
929
1067
  }
930
- const lines = [];
931
- const run = async (argv) => {
932
- const handle = sub.spawn({
933
- argv,
934
- stdio: {
935
- stdout: { maxBytes: 8 << 20 },
936
- stderr: { maxBytes: 65536 },
937
- stdin: "ignore"
938
- },
939
- graceMs: 2e4
940
- });
941
- const outcome = await handle.done;
942
- const code = outcome?.exitCode ?? outcome?.code;
943
- if (code !== 0) throw new Error("scan exit " + code);
944
- const text = handle.collected?.stdout?.readFrom(0).text ?? "";
945
- for (const ln of text.split(/\r?\n/)) {
946
- const s = ln.trim();
947
- if (s) lines.push(s);
1068
+ const code = outcome.exitCode ?? outcome.code;
1069
+ if (code !== 0 && code !== 1) throw new Error("ripgrep 退出码:" + String(code));
1070
+ const parsed = parseOutput(handle, input.maxResults);
1071
+ if (!handle.collected?.stdout) throw new Error("ripgrep stdout 不可用");
1072
+ return {
1073
+ ...parsed,
1074
+ source: "ripgrep"
1075
+ };
1076
+ }
1077
+ /**
1078
+ * 构造 ripgrep provider。
1079
+ * @author ddj 2026年08月24号
1080
+ * @returns provider 实例
1081
+ */
1082
+ function newRgProvider() {
1083
+ return { search: searchRipgrep };
1084
+ }
1085
+ //#endregion
1086
+ //#region src/search/fallback.ts
1087
+ const RESULT_CAP = 500;
1088
+ const EXCLUDED = /* @__PURE__ */ new Set([
1089
+ "node_modules",
1090
+ ".git",
1091
+ ".tmp",
1092
+ ".cache",
1093
+ "dist",
1094
+ "build",
1095
+ "vendor",
1096
+ "coverage",
1097
+ "__pycache__"
1098
+ ]);
1099
+ /**
1100
+ * 在一个目录树中递归查找匹配文件。
1101
+ * @author ddj 2026年08月24号
1102
+ * @param fs 文件系统能力
1103
+ * @param target 当前目录目标
1104
+ * @param input 搜索输入
1105
+ * @param files 已保留候选
1106
+ * @param signal 取消信号
1107
+ * @returns 是否因达到上限而截断
1108
+ */
1109
+ async function walk(fs, target, input, files, signal) {
1110
+ if (signal.aborted) throw new DOMException("搜索已取消", "AbortError");
1111
+ const entries = await fs.listDir(target, signal);
1112
+ for (const entry of entries) {
1113
+ if (signal.aborted) throw new DOMException("搜索已取消", "AbortError");
1114
+ if (entry.type === "directory") {
1115
+ if (EXCLUDED.has(entry.name)) continue;
1116
+ if (await walk(fs, entry.target, input, files, signal)) return true;
1117
+ continue;
948
1118
  }
1119
+ if (entry.type !== "file") continue;
1120
+ const path = pathText(entry.target.displayPath ?? entry.name);
1121
+ if (!pathMatch(path, {
1122
+ raw: input.query,
1123
+ text: pathText(input.query).toLocaleLowerCase("en-US")
1124
+ })) continue;
1125
+ files.push(path);
1126
+ if (files.length >= Math.min(input.maxResults, RESULT_CAP)) return true;
1127
+ }
1128
+ return false;
1129
+ }
1130
+ /**
1131
+ * 使用 ctx.fs.listDir 搜索文件。
1132
+ * @author ddj 2026年08月24号
1133
+ * @param input provider 输入
1134
+ * @returns 有界 fallback 结果
1135
+ */
1136
+ async function searchFallback(input) {
1137
+ const fs = input.ctx.get("fs");
1138
+ if (!fs) throw new Error("缺少 fs");
1139
+ const policy = policyOf(input.ctx, input.session);
1140
+ const cwd = input.session?.header?.cwd;
1141
+ const rootTarget = await fs.resolve(policy?.workspaceRoot ?? ".", cwd ? {
1142
+ cwd,
1143
+ signal: input.signal
1144
+ } : { signal: input.signal });
1145
+ const signal = input.signal ?? new AbortController().signal;
1146
+ const files = [];
1147
+ const truncated = await walk(fs, rootTarget, input, files, signal);
1148
+ return {
1149
+ files,
1150
+ truncated,
1151
+ complete: !truncated,
1152
+ source: "fallback"
949
1153
  };
950
- try {
951
- if (process.platform === "win32") await run([
952
- "powershell",
953
- "-NoProfile",
954
- "-NonInteractive",
955
- "-Command",
956
- "Get-ChildItem -LiteralPath \"" + String(root).replace(/"/g, "\"\"") + "\" -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '(node_modules|\\.git|\\.tmp|\\.cache|dist|build|vendor|coverage|__pycache__)' } | Select-Object -First 6000 | ForEach-Object { $_.FullName }"
957
- ]);
958
- else await run([
959
- "/bin/sh",
960
- "-c",
961
- "find " + JSON.stringify(root) + " -type f -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/.tmp/*' -not -path '*/.cache/*' -not -path '*/dist/*' -not -path '*/build/*' -not -path '*/vendor/*' -not -path '*/coverage/*' 2>/dev/null | head -n 6000"
962
- ]);
963
- } catch (error) {
964
- console.error("edrv scan failed", error);
965
- return null;
1154
+ }
1155
+ /**
1156
+ * 构造 fallback provider。
1157
+ * @author ddj 2026年08月24号
1158
+ * @returns provider 实例
1159
+ */
1160
+ function newFallback() {
1161
+ return { search: searchFallback };
1162
+ }
1163
+ //#endregion
1164
+ //#region src/search/ranker.ts
1165
+ /**
1166
+ * 生成搜索候选。
1167
+ * @author ddj 2026年08月24号
1168
+ * @param path 文件路径
1169
+ * @param source 候选来源
1170
+ * @returns 内部候选
1171
+ */
1172
+ function candidateOf(path, source) {
1173
+ const normalizedPath = pathText(path);
1174
+ return {
1175
+ path,
1176
+ basename: normalizedPath.split("/").pop() ?? normalizedPath,
1177
+ normalizedPath: normalizedPath.toLocaleLowerCase("en-US"),
1178
+ source
1179
+ };
1180
+ }
1181
+ /**
1182
+ * 对连续匹配候选进行排序。
1183
+ * @author ddj 2026年08月24号
1184
+ * @param candidates 候选列表
1185
+ * @param query 已规范化 query
1186
+ * @returns 排序后的候选
1187
+ */
1188
+ function rankCandidates(candidates, query) {
1189
+ const text = query.text;
1190
+ const score = (candidate) => {
1191
+ const base = candidate.basename.toLocaleLowerCase("en-US");
1192
+ if (base.startsWith(text)) return 0;
1193
+ if (base.includes(text)) return 1;
1194
+ if (candidate.normalizedPath.includes(text)) return 2;
1195
+ return 3;
1196
+ };
1197
+ return [...candidates].sort((left, right) => score(left) - score(right) || left.normalizedPath.localeCompare(right.normalizedPath, "en-US"));
1198
+ }
1199
+ //#endregion
1200
+ //#region src/search/orchestrator.ts
1201
+ const CACHE_TTL = 6e4;
1202
+ const CACHE_LIMIT = 100;
1203
+ const RESULT_LIMIT = 50;
1204
+ const PROVIDER_VERSION = "ripgrep-v1";
1205
+ const POLICY_VERSION = "search-policy-v1";
1206
+ /**
1207
+ * 短期有界搜索缓存。
1208
+ * @author ddj 2026年08月24号
1209
+ * @returns 缓存对象
1210
+ */
1211
+ var SearchCache = class {
1212
+ entries = /* @__PURE__ */ new Map();
1213
+ /**
1214
+ * 读取未过期条目。
1215
+ * @author ddj 2026年08月24号
1216
+ * @param key 缓存 key
1217
+ * @param now 当前时间
1218
+ * @returns 缓存结果或 undefined
1219
+ */
1220
+ get(key, now = Date.now()) {
1221
+ const entry = this.entries.get(key);
1222
+ if (!entry) return void 0;
1223
+ if (now - entry.at >= CACHE_TTL) {
1224
+ this.entries.delete(key);
1225
+ return;
1226
+ }
1227
+ this.entries.delete(key);
1228
+ this.entries.set(key, entry);
1229
+ return {
1230
+ ...entry.result,
1231
+ files: [...entry.result.files]
1232
+ };
966
1233
  }
967
- const files = lines.slice(0, SCAN_CAP);
968
- if (files.length) fileIndex.set(cwd, {
969
- at: Date.now(),
970
- files
971
- });
972
- return files;
1234
+ /**
1235
+ * 写入成功 provider 结果。
1236
+ * @author ddj 2026年08月24号
1237
+ * @param key 缓存 key
1238
+ * @param result provider 结果
1239
+ * @param now 当前时间
1240
+ */
1241
+ set(key, result, now = Date.now()) {
1242
+ this.entries.delete(key);
1243
+ this.entries.set(key, {
1244
+ at: now,
1245
+ result: {
1246
+ ...result,
1247
+ files: [...result.files]
1248
+ }
1249
+ });
1250
+ while (this.entries.size > CACHE_LIMIT) this.entries.delete(this.entries.keys().next().value);
1251
+ }
1252
+ /**
1253
+ * 清理指定根目录的缓存。
1254
+ * @author ddj 2026年08月24号
1255
+ * @param root 工作区根目录
1256
+ */
1257
+ clearRoot(root) {
1258
+ for (const key of this.entries.keys()) if (key.startsWith(root + "|")) this.entries.delete(key);
1259
+ }
1260
+ /**
1261
+ * 清空全部缓存。
1262
+ * @author ddj 2026年08月24号
1263
+ */
1264
+ clear() {
1265
+ this.entries.clear();
1266
+ }
1267
+ };
1268
+ /**
1269
+ * 获取 policy 版本,未知策略使用固定插件版本。
1270
+ * @author ddj 2026年08月24号
1271
+ * @param ctx DSH 上下文
1272
+ * @param session 当前会话
1273
+ * @returns 稳定版本文本
1274
+ */
1275
+ function policyVersion(ctx, session) {
1276
+ const policy = policyOf(ctx, session);
1277
+ const version = policy?.version ?? policy?.policyVersion;
1278
+ return typeof version === "string" || typeof version === "number" ? String(version) : POLICY_VERSION;
973
1279
  }
974
- /** 会话销毁时清理文件索引缓存。 */
975
- function dropFileIndex(cwd) {
976
- fileIndex.delete(cwd);
1280
+ /**
1281
+ * 将 root 内绝对路径统一成相对显示路径。
1282
+ * @author ddj 2026年08月24号
1283
+ * @param value 原始路径
1284
+ * @param root 搜索根
1285
+ * @returns 兼容 edrv.read 的路径
1286
+ */
1287
+ function displayPath(value, root) {
1288
+ const path = pathText(value);
1289
+ const base = pathText(root).replace(/\/$/, "");
1290
+ const lowerPath = path.toLocaleLowerCase("en-US");
1291
+ const lowerBase = base.toLocaleLowerCase("en-US");
1292
+ if (lowerPath === lowerBase) return ".";
1293
+ if (lowerPath.startsWith(lowerBase + "/")) return path.slice(base.length + 1);
1294
+ return path;
1295
+ }
1296
+ /**
1297
+ * 为显示路径生成跨绝对/相对形式的去重 key。
1298
+ * @author ddj 2026年08月24号
1299
+ * @param value 原始路径
1300
+ * @param root 搜索根
1301
+ * @returns 去重 key
1302
+ */
1303
+ function displayKey(value, root) {
1304
+ return pathKey(displayPath(value, root));
1305
+ }
1306
+ /**
1307
+ * 合并 provider 与 active diff 候选并去重。
1308
+ * @author ddj 2026年08月24号
1309
+ * @param result provider 结果
1310
+ * @param activePaths 活跃差异路径
1311
+ * @param root 搜索根
1312
+ * @param query 已规范化 query
1313
+ * @returns 候选列表
1314
+ */
1315
+ function mergeCandidates(result, activePaths, root, query) {
1316
+ const candidates = /* @__PURE__ */ new Map();
1317
+ for (const path of result.files) {
1318
+ const shown = displayPath(path, root);
1319
+ if (pathMatch(shown, query)) candidates.set(displayKey(path, root), candidateOf(shown, "workspace"));
1320
+ }
1321
+ for (const path of activePaths) {
1322
+ const shown = displayPath(path, root);
1323
+ if (!pathMatch(shown, query)) continue;
1324
+ const key = displayKey(path, root);
1325
+ if (!candidates.has(key)) candidates.set(key, candidateOf(shown, "active-diff"));
1326
+ }
1327
+ return [...candidates.values()];
1328
+ }
1329
+ /**
1330
+ * 搜索编排器。
1331
+ * @author ddj 2026年08月24号
1332
+ */
1333
+ var SearchOrchestrator = class {
1334
+ ctx;
1335
+ cache = new SearchCache();
1336
+ inflight = /* @__PURE__ */ new Map();
1337
+ rootsByCwd = /* @__PURE__ */ new Map();
1338
+ provider;
1339
+ fallback;
1340
+ ranker;
1341
+ /**
1342
+ * 创建搜索编排器。
1343
+ * @author ddj 2026年08月24号
1344
+ * @param ctx DSH 上下文
1345
+ * @param provider 主 provider,可替换测试
1346
+ * @param fallback 降级 provider,可替换测试
1347
+ * @param ranker 排序器,可替换测试
1348
+ */
1349
+ constructor(ctx, provider = newRgProvider(), fallback = newFallback(), ranker = { rank: rankCandidates }) {
1350
+ this.ctx = ctx;
1351
+ this.provider = provider;
1352
+ this.fallback = fallback;
1353
+ this.ranker = ranker;
1354
+ }
1355
+ /**
1356
+ * 执行一次工作区搜索。
1357
+ * @author ddj 2026年08月24号
1358
+ * @param request 搜索请求
1359
+ * @returns 旧 RPC 响应字段
1360
+ */
1361
+ async search(request) {
1362
+ const query = prepareQuery(request.query);
1363
+ if (query.text.length < 2) return {
1364
+ files: [],
1365
+ truncated: false
1366
+ };
1367
+ let root;
1368
+ try {
1369
+ root = await searchRoot(this.ctx, request.session);
1370
+ } catch (error) {
1371
+ return this.rankActive(request.activePaths, request.cwd, query);
1372
+ }
1373
+ const rootKey = pathText(root);
1374
+ const roots = this.rootsByCwd.get(request.cwd) ?? /* @__PURE__ */ new Set();
1375
+ roots.add(rootKey);
1376
+ this.rootsByCwd.set(request.cwd, roots);
1377
+ const key = [
1378
+ rootKey,
1379
+ query.text,
1380
+ policyVersion(this.ctx, request.session),
1381
+ PROVIDER_VERSION
1382
+ ].join("|");
1383
+ const cached = this.cache.get(key);
1384
+ if (cached) return this.finish(cached, request.activePaths, root, query);
1385
+ this.inflight.get(root)?.abort();
1386
+ const controller = new AbortController();
1387
+ this.inflight.set(root, controller);
1388
+ try {
1389
+ let result;
1390
+ let providerOk = true;
1391
+ try {
1392
+ result = await this.provider.search({
1393
+ ctx: this.ctx,
1394
+ session: request.session,
1395
+ cwd: request.cwd,
1396
+ query: query.raw,
1397
+ maxResults: 500,
1398
+ signal: controller.signal,
1399
+ root
1400
+ });
1401
+ } catch (error) {
1402
+ providerOk = false;
1403
+ if (controller.signal.aborted) return {
1404
+ files: [],
1405
+ truncated: false
1406
+ };
1407
+ try {
1408
+ result = await this.fallback.search({
1409
+ ctx: this.ctx,
1410
+ session: request.session,
1411
+ cwd: request.cwd,
1412
+ query: query.raw,
1413
+ maxResults: 500,
1414
+ signal: controller.signal,
1415
+ root
1416
+ });
1417
+ } catch (fallbackError) {
1418
+ return this.rankActive(request.activePaths, root, query);
1419
+ }
1420
+ }
1421
+ if (controller.signal.aborted) return {
1422
+ files: [],
1423
+ truncated: false
1424
+ };
1425
+ if (providerOk) this.cache.set(key, result);
1426
+ return this.finish(result, request.activePaths, root, query);
1427
+ } finally {
1428
+ if (this.inflight.get(root) === controller) this.inflight.delete(root);
1429
+ }
1430
+ }
1431
+ /**
1432
+ * 清理会话对应根目录的缓存和在途搜索。
1433
+ * @author ddj 2026年08月24号
1434
+ * @param cwd 会话工作区
1435
+ */
1436
+ dispose(cwd) {
1437
+ const roots = this.rootsByCwd.get(cwd) ?? /* @__PURE__ */ new Set([cwd]);
1438
+ for (const root of roots) {
1439
+ this.inflight.get(root)?.abort();
1440
+ this.inflight.delete(root);
1441
+ this.cache.clearRoot(root);
1442
+ }
1443
+ this.rootsByCwd.delete(cwd);
1444
+ }
1445
+ /**
1446
+ * 清理全部状态。
1447
+ * @author ddj 2026年08月24号
1448
+ */
1449
+ disposeAll() {
1450
+ for (const controller of this.inflight.values()) controller.abort();
1451
+ this.inflight.clear();
1452
+ this.rootsByCwd.clear();
1453
+ this.cache.clear();
1454
+ }
1455
+ /**
1456
+ * 将 provider 结果与 active diff 合并、排序和截断。
1457
+ * @author ddj 2026年08月24号
1458
+ * @param result provider 结果
1459
+ * @param activePaths active diff 路径
1460
+ * @param root 搜索根
1461
+ * @param query 已规范化 query
1462
+ * @returns 旧 RPC 响应字段
1463
+ */
1464
+ finish(result, activePaths, root, query) {
1465
+ const candidates = this.ranker.rank(mergeCandidates(result, activePaths, root, query), query);
1466
+ return {
1467
+ files: candidates.slice(0, RESULT_LIMIT).map((candidate) => candidate.path),
1468
+ truncated: result.truncated || candidates.length > RESULT_LIMIT
1469
+ };
1470
+ }
1471
+ /**
1472
+ * provider 失败时仅保留 active diff 命中。
1473
+ * @author ddj 2026年08月24号
1474
+ * @param paths active diff 路径
1475
+ * @param root 搜索根
1476
+ * @param query 已规范化 query
1477
+ * @returns 旧 RPC 响应字段
1478
+ */
1479
+ rankActive(paths, root, query) {
1480
+ return this.finish({
1481
+ files: [],
1482
+ truncated: false,
1483
+ complete: true,
1484
+ source: "active-diff"
1485
+ }, paths, root, query);
1486
+ }
1487
+ };
1488
+ /**
1489
+ * 创建默认编排器。
1490
+ * @author ddj 2026年08月24号
1491
+ * @param ctx DSH 上下文
1492
+ * @returns 搜索编排器
1493
+ */
1494
+ function newSearcher(ctx) {
1495
+ return new SearchOrchestrator(ctx);
977
1496
  }
978
1497
  //#endregion
979
1498
  //#region src/revert.ts
@@ -1504,6 +2023,74 @@ async function projectRefresh(ctx, workspacePath, serverName) {
1504
2023
  return projectOf(ctx, workspacePath, workspace.title ?? "");
1505
2024
  }
1506
2025
  //#endregion
2026
+ //#region src/fileOpenSettings.ts
2027
+ /**
2028
+ * dsh-vscode-mode host — 文件链接打开工具的持久化设置。
2029
+ * @author ddj 2026年08月24号
2030
+ */
2031
+ const FILE_OPEN_SETTINGS_NS = settingsNamespace("dsh-vscode-mode");
2032
+ const FILE_OPEN_DEFAULT = "auto";
2033
+ const FILE_OPEN_SCHEMA = z.object({ fileOpenTool: z.string().default(FILE_OPEN_DEFAULT) });
2034
+ function normalizeValue(value) {
2035
+ if (typeof value !== "string" || value.trim() === "") return FILE_OPEN_DEFAULT;
2036
+ return value.trim();
2037
+ }
2038
+ function configValue(config) {
2039
+ return normalizeValue(config?.fileOpenTool);
2040
+ }
2041
+ /**
2042
+ * 注册设置命名空间,并提供 host 侧的读取与更新状态。
2043
+ * @author ddj 2026年08月24号
2044
+ * @param ctx DSH host 上下文
2045
+ * @param config 插件组合配置
2046
+ * @param onChange 设置变化回调
2047
+ * @returns 设置状态
2048
+ */
2049
+ function setupOpenSettings(ctx, config, onChange) {
2050
+ let current = configValue(config);
2051
+ let revision;
2052
+ let provider;
2053
+ const notify = (value) => {
2054
+ current = normalizeValue(value);
2055
+ onChange(current);
2056
+ };
2057
+ const syncRevision = () => {
2058
+ revision = (provider?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.revision;
2059
+ };
2060
+ const setSource = (source) => notify(source().fileOpenTool);
2061
+ const settingsChange = () => syncRevision();
2062
+ installSettingsSection(ctx, FILE_OPEN_SETTINGS_NS, FILE_OPEN_SCHEMA, { fileOpenTool: current }, {
2063
+ setSource,
2064
+ onChange: settingsChange
2065
+ });
2066
+ ctx.inject?.(["settings"], (settingsCtx) => {
2067
+ provider = settingsCtx.get("settings");
2068
+ syncRevision();
2069
+ });
2070
+ return {
2071
+ get value() {
2072
+ return current;
2073
+ },
2074
+ get revision() {
2075
+ return revision;
2076
+ },
2077
+ update: async (value, expectedRevision) => {
2078
+ const next = normalizeValue(value);
2079
+ if (!provider?.update) {
2080
+ notify(next);
2081
+ return;
2082
+ }
2083
+ await provider.update(FILE_OPEN_SETTINGS_NS, { fileOpenTool: next }, expectedRevision);
2084
+ const stored = (provider.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS))?.value;
2085
+ notify(stored?.fileOpenTool ?? next);
2086
+ syncRevision();
2087
+ }
2088
+ };
2089
+ }
2090
+ function normalizeFileOpenTool(value) {
2091
+ return normalizeValue(value);
2092
+ }
2093
+ //#endregion
1507
2094
  //#region src/rpc.ts
1508
2095
  /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
1509
2096
  const debugWriteQueues = /* @__PURE__ */ new Map();
@@ -1540,7 +2127,7 @@ async function requireSession(ctx, sessionId) {
1540
2127
  };
1541
2128
  }
1542
2129
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
1543
- function buildHandlers(ctx, registry) {
2130
+ function buildHandlers(ctx, registry, searcher = newSearcher(ctx)) {
1544
2131
  return {
1545
2132
  "edrv.list": async (args) => {
1546
2133
  const sc = await requireSession(ctx, args.sessionId);
@@ -1868,28 +2455,17 @@ function buildHandlers(ctx, registry) {
1868
2455
  ok: false,
1869
2456
  error: sc.err
1870
2457
  };
1871
- const query = String(args.query ?? "").trim().toLowerCase();
1872
- const files = [];
1873
- if (query.length >= 2) {
1874
- const bucket = await bucketOf(registry, ctx, sc.cwd);
1875
- const pool = /* @__PURE__ */ new Set();
1876
- for (const rec of bucket.values()) if (rec.path) pool.add(rec.path);
1877
- const listed = await listWorkspaceFiles(ctx, sc.session, sc.cwd);
1878
- if (listed) for (const f of listed) pool.add(f);
1879
- const score = (p) => {
1880
- const pl = p.toLowerCase();
1881
- if ((pl.split(/[\\/]/).pop() || "").includes(query)) return 0;
1882
- if (pl.includes(query)) return 1;
1883
- return -1;
1884
- };
1885
- const hit = [...pool].filter((p) => score(p) >= 0);
1886
- hit.sort((a, b) => score(a) - score(b) || (a < b ? -1 : 1));
1887
- files.push(...hit.slice(0, 50));
1888
- }
2458
+ const bucket = await bucketOf(registry, ctx, sc.cwd);
2459
+ const activePaths = [];
2460
+ for (const record of bucket.values()) if (record.path) activePaths.push(record.path);
1889
2461
  return {
1890
2462
  ok: true,
1891
- files,
1892
- truncated: files.length >= 50
2463
+ ...await searcher.search({
2464
+ session: sc.session,
2465
+ cwd: sc.cwd,
2466
+ query: args.query,
2467
+ activePaths
2468
+ })
1893
2469
  };
1894
2470
  },
1895
2471
  "mcp.list": async () => ({
@@ -2010,6 +2586,37 @@ function buildHandlers(ctx, registry) {
2010
2586
  error: String(error)
2011
2587
  };
2012
2588
  }
2589
+ },
2590
+ "vscode.fileOpenSettingsGet": async () => {
2591
+ const descriptor = ctx.get("settings")?.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS);
2592
+ const value = descriptor?.value;
2593
+ return {
2594
+ ok: true,
2595
+ fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool ?? "auto"),
2596
+ revision: descriptor?.revision
2597
+ };
2598
+ },
2599
+ "vscode.fileOpenSettingsUpdate": async (args) => {
2600
+ const settings = ctx.get("settings");
2601
+ if (!settings?.update) return {
2602
+ ok: false,
2603
+ error: "设置服务不可用"
2604
+ };
2605
+ try {
2606
+ await settings.update(FILE_OPEN_SETTINGS_NS, { fileOpenTool: normalizeFileOpenTool(args.fileOpenTool) }, args.expectedRevision);
2607
+ const descriptor = settings.describe?.({ redactSecrets: true })?.find((item) => item.ns === FILE_OPEN_SETTINGS_NS);
2608
+ const value = descriptor?.value;
2609
+ return {
2610
+ ok: true,
2611
+ fileOpenTool: normalizeFileOpenTool(value?.fileOpenTool),
2612
+ revision: descriptor?.revision
2613
+ };
2614
+ } catch (error) {
2615
+ return {
2616
+ ok: false,
2617
+ error: String(error)
2618
+ };
2619
+ }
2013
2620
  }
2014
2621
  };
2015
2622
  }
@@ -2017,8 +2624,8 @@ function buildHandlers(ctx, registry) {
2017
2624
  * 统一入口:按方法分发到 handler 表。
2018
2625
  * @author ddj 2026年08月20号
2019
2626
  */
2020
- async function handleRpc(ctx, registry, method, args) {
2021
- const handler = buildHandlers(ctx, registry)[method];
2627
+ async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx)) {
2628
+ const handler = buildHandlers(ctx, registry, searcher)[method];
2022
2629
  if (!handler) return {
2023
2630
  ok: false,
2024
2631
  error: "未知方法: " + String(method)
@@ -2161,6 +2768,14 @@ function installIsolation(ctx) {
2161
2768
  }, "vscode-mode:mcp-isolation");
2162
2769
  }
2163
2770
  //#endregion
2771
+ //#region src/workspace.ts
2772
+ /** cwd → { at, files }(随会话销毁清理)。 */
2773
+ const fileIndex = /* @__PURE__ */ new Map();
2774
+ /** 会话销毁时清理文件索引缓存。 */
2775
+ function dropFileIndex(cwd) {
2776
+ fileIndex.delete(cwd);
2777
+ }
2778
+ //#endregion
2164
2779
  //#region src/index.ts
2165
2780
  /**
2166
2781
  * @dsh-external 生态 → dsh-vscode-mode:DSH 上的类 VSCode 编码体验(Host 半入口)。
@@ -2187,6 +2802,8 @@ const inject = [
2187
2802
  */
2188
2803
  function apply(ctx, config) {
2189
2804
  const registry = /* @__PURE__ */ new Map();
2805
+ const searcher = newSearcher(ctx);
2806
+ setupOpenSettings(ctx, config, () => {});
2190
2807
  ctx.on("tools/result", (exec, result) => {
2191
2808
  captureToolResult(ctx, registry, exec, result);
2192
2809
  });
@@ -2195,9 +2812,10 @@ function apply(ctx, config) {
2195
2812
  if (cwd) {
2196
2813
  registry.delete(cwd);
2197
2814
  dropFileIndex(cwd);
2815
+ searcher.dispose(cwd);
2198
2816
  }
2199
2817
  });
2200
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args));
2818
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher));
2201
2819
  installIsolation(ctx);
2202
2820
  ctx.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)");
2203
2821
  }