dsh-vscode-mode 0.1.29 → 0.1.32

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * dsh-vscode-mode host — profile patch 压缩调优(文本行级,零 YAML 依赖)。
3
+ * 在 ~/.dsh/profiles/<profile>/cordis.patch.yml 末尾追加/移除一段带标记注释的
4
+ * id 定向覆盖块(compaction-basic / tool-result-pruner 更低阈值)。
5
+ * 纯文本函数可单测:幂等追加、整段移除、无块原样返回。
6
+ * 作者 ddj 2026-09-02
7
+ */
8
+
9
+ /** 性能块起始标记注释(文件内唯一锚点)。 */
10
+ export const PERF_MARK_START = '# dsh-vscode-mode perf tuning (start)'
11
+ /** 性能块结束标记注释。 */
12
+ export const PERF_MARK_END = '# dsh-vscode-mode perf tuning (end)'
13
+
14
+ /**
15
+ * 建议的压缩调优 YAML 块(顶层 loader patch 条目,id 定向覆盖)。
16
+ * - compaction-basic:thresholdRatio 0.8→0.6、retainRatio 0.16→0.12(更早触发压缩);
17
+ * - tool-result-pruner:thresholdChars 8192→4096、head/tail 同步收窄(大 tool result 提前截断)。
18
+ * 注意:压缩只降低模型可见上下文压力,不重写已持久化会话日志(append-only)。
19
+ * @author ddj 2026年09月02号
20
+ * @returns 带前后标记注释与尾换行的 YAML 块
21
+ */
22
+ export function perfConfigBlock(): string {
23
+ return [
24
+ PERF_MARK_START,
25
+ '# 更早触发会话内压缩:降低模型可见上下文压力(仅影响后续轮次,不缩小已持久化日志)。',
26
+ '- id: compaction-basic',
27
+ ' config:',
28
+ ' thresholdRatio: 0.6',
29
+ ' retainRatio: 0.12',
30
+ '- id: tool-result-pruner',
31
+ ' config:',
32
+ ' thresholdChars: 4096',
33
+ ' headChars: 2048',
34
+ ' tailChars: 512',
35
+ PERF_MARK_END,
36
+ '',
37
+ ].join('\n')
38
+ }
39
+
40
+ /** 当前文本是否已包含性能块。 */
41
+ export function patchHasPerfConfig(text: string): boolean {
42
+ return text.includes(PERF_MARK_START)
43
+ }
44
+
45
+ /**
46
+ * 移除性能块(起止标记注释之间的整段,含所在行),无块时原样返回。
47
+ * 移除后压缩多余空行、确保以单个换行结尾。
48
+ * @author ddj 2026年09月02号
49
+ * @param text 原 patch 文本
50
+ * @returns 移除后的文本
51
+ */
52
+ export function patchRemovePerfConfig(text: string): string {
53
+ const start = text.indexOf(PERF_MARK_START)
54
+ if (start === -1) return text
55
+ const end = text.indexOf(PERF_MARK_END, start)
56
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1
57
+ if (end === -1) return text.slice(0, lineStart).replace(/\n+$/, '\n')
58
+ const after = (() => {
59
+ const nl = text.indexOf('\n', end)
60
+ return nl === -1 ? text.length : nl + 1
61
+ })()
62
+ const removed = (text.slice(0, lineStart) + text.slice(after)).replace(/\n{3,}/g, '\n\n')
63
+ return removed.replace(/\n+$/, '\n')
64
+ }
65
+
66
+ /**
67
+ * 幂等追加性能块:已有块先整段移除再追加到文件末尾(保证全文件只有一份、位置固定)。
68
+ * @author ddj 2026年09月02号
69
+ * @param text 原 patch 文本
70
+ * @returns 追加后的文本
71
+ */
72
+ export function patchInsertPerfConfig(text: string): string {
73
+ const base = patchRemovePerfConfig(text)
74
+ const trimmed = base.replace(/\n+$/, '')
75
+ return trimmed + '\n' + perfConfigBlock()
76
+ }
package/src/routes.ts CHANGED
@@ -4,12 +4,12 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import { readFile, stat } from 'node:fs/promises'
7
- import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
8
- import { fileURLToPath } from 'node:url'
7
+ import { extname, join, normalize, resolve, sep } from 'node:path'
9
8
  import { ROUTE_PREFIX, noteOwnRoute, resetOwnRoutes, routeConflict } from './compat.js'
10
9
  import { RPC_PATH } from './shared/rpc.js'
11
10
  import type { RpcMethod, RpcRequestMap } from './shared/rpc.js'
12
11
  import type { Ctx } from './store.js'
12
+ import { VENDOR_PREFIX, imageDirOf, vendorDirOf } from './paths.js'
13
13
 
14
14
  const IMG_ROUTES = [
15
15
  { url: '/edrv/assets/compare-idle.png', file: 'compare_idle.png' },
@@ -17,7 +17,6 @@ const IMG_ROUTES = [
17
17
  ]
18
18
 
19
19
  /** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
20
- const VENDOR_PREFIX = '/edrv/vendor'
21
20
  const VENDOR_MIME: Record<string, string> = {
22
21
  '.js': 'text/javascript; charset=utf-8',
23
22
  '.css': 'text/css; charset=utf-8',
@@ -35,15 +34,11 @@ const VENDOR_MIME: Record<string, string> = {
35
34
  '.md': 'text/markdown',
36
35
  }
37
36
 
38
- /** 图标目录:默认插件包内 assets/(随包发布);config.imageDir 可覆盖(用于自定义换图)。 */
39
- function imageDirOf(config: unknown): string {
40
- const cfg = config as { imageDir?: unknown } | undefined
41
- if (cfg && typeof cfg.imageDir === 'string' && cfg.imageDir) return cfg.imageDir.replace(/\\/g, '/')
42
- return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets')
43
- }
37
+ /** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
38
+ const imgDirOf = (config: unknown): string => imageDirOf(config, import.meta.url)
44
39
 
45
- /** Monaco AMD 静态目录(随包发布)。 */
46
- const vendorDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'vendor')
40
+ /** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
41
+ const vendorDir = vendorDirOf(import.meta.url)
47
42
 
48
43
  /**
49
44
  * 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
@@ -59,7 +54,7 @@ export function registerRoutes(
59
54
  handleRpc: <M extends RpcMethod>(method: M, args: RpcRequestMap[M]) => Promise<unknown>,
60
55
  onWarning?: (warning: string) => void,
61
56
  ): void {
62
- const imgDir = imageDirOf(config)
57
+ const imgDir = imgDirOf(config)
63
58
  const web = ctx.get('webServer')
64
59
  if (!web) return
65
60
 
package/src/rpc.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  resolveTarget,
19
19
  saveBucket,
20
20
  } from './store.js'
21
+ import { mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'
22
+ import { basename, join } from 'node:path'
21
23
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
22
24
  import type { Registry } from './registry.js'
23
25
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
@@ -30,14 +32,87 @@ import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
30
32
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
31
33
  import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
32
34
  import { buildReport } from './compat.js'
33
- import { readDevForm, setDevForm } from './devForm.js'
34
- import { normalizeRel, toTreeEntries } from './tree.js'
35
+ import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
36
+ import { normalizeRel } from './tree.js'
37
+ import { invalidateIndex, listDirCached } from './treeIndex.js'
35
38
  import { revealInExplorer } from './reveal.js'
39
+ import { DEBUG_LOG, debugLogFile, dshHome, pluginLogRoot } from './paths.js'
40
+ import { markActiveSessions, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf } from './perf.js'
41
+ import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
36
42
 
37
- /** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
43
+ /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
44
+ const debugBuffers = new Map<string, { lines: string[]; len: number; timer: ReturnType<typeof setTimeout> | null }>()
45
+ /** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
38
46
  const debugWriteQueues = new Map<string, Promise<void>>()
39
47
  /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
40
48
  const DEBUG_LOG_CAP = 512 * 1024
49
+ /** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
50
+ const DEBUG_BUF_CAP = 32 * 1024
51
+ /** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
52
+ const DEBUG_FLUSH_IDLE_MS = 1000
53
+ /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
54
+ const staleCheckedAt = new Map<string, number>()
55
+ /** stale 自动清理最小间隔。 */
56
+ const STALE_CHECK_MIN_MS = 10_000
57
+
58
+ /**
59
+ * 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
60
+ * 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
61
+ * @author ddj 2026年08月26号 / 2026年09月01号
62
+ * @param ctx DSH 上下文
63
+ * @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
64
+ * @param line 单条日志文本(不含换行)
65
+ */
66
+ function enqueueDebug(ctx: Ctx, cwd: string, line: string): void {
67
+ const st = debugBuffers.get(cwd) ?? { lines: [], len: 0, timer: null }
68
+ st.lines.push(line)
69
+ st.len += line.length
70
+ const flush = () => {
71
+ st.timer = null
72
+ const batch = st.lines
73
+ st.lines = []
74
+ st.len = 0
75
+ void flushDebug(cwd, batch)
76
+ }
77
+ if (st.len >= DEBUG_BUF_CAP) {
78
+ if (st.timer) clearTimeout(st.timer)
79
+ flush()
80
+ } else if (!st.timer) {
81
+ st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS)
82
+ }
83
+ debugBuffers.set(cwd, st)
84
+ }
85
+
86
+ /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
87
+ const debugLegacyCleaned = new Set<string>()
88
+
89
+ /**
90
+ * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
91
+ * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
92
+ * @author ddj 2026年08月26号 / 2026年09月01号
93
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
94
+ * @param batch 本批日志行
95
+ */
96
+ function flushDebug(cwd: string, batch: string[]): Promise<void> {
97
+ const prev = debugWriteQueues.get(cwd) ?? Promise.resolve()
98
+ const task = prev.then(async () => {
99
+ try {
100
+ await mkdir(pluginLogRoot(), { recursive: true })
101
+ const target = debugLogFile(cwd)
102
+ const old = await readFile(target, 'utf8').catch(() => '')
103
+ const appended = old + batch.map((l) => new Date().toISOString() + ' ' + l + '\n').join('')
104
+ const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended
105
+ await writeFile(target, next, 'utf8')
106
+ // 迁移后一次性清理旧工作区 debug 日志(best-effort)
107
+ if (!debugLegacyCleaned.has(cwd)) {
108
+ debugLegacyCleaned.add(cwd)
109
+ await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {})
110
+ }
111
+ } catch (e) { /* 写日志失败忽略 */ }
112
+ })
113
+ debugWriteQueues.set(cwd, task)
114
+ return task
115
+ }
41
116
 
42
117
  /** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
43
118
  function recView(record: DiffRecord): RecordView {
@@ -118,6 +193,28 @@ async function applyDecisions(
118
193
  return results
119
194
  }
120
195
 
196
+ /** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
197
+ function activeSessionIds(ctx: Ctx): Set<string> {
198
+ const sessions = ctx.get('sessions')
199
+ if (!sessions || typeof sessions.list !== 'function') return new Set()
200
+ return new Set((sessions.list() as Session[]).map((s) => s.id).filter(Boolean))
201
+ }
202
+
203
+ /** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
204
+ async function patchFileInfo(): Promise<{ profileDir?: string; patchPath?: string }> {
205
+ const profileDir = findProfileDir()
206
+ if (!profileDir) return {}
207
+ return { profileDir, patchPath: join(profileDir, 'cordis.patch.yml') }
208
+ }
209
+
210
+ /** 最新一次压缩配置备份文件名(cordis.patch.yml.bak-<ts>,按名倒序取新)。 */
211
+ async function latestPatchBackup(patchPath: string): Promise<string | undefined> {
212
+ const dir = join(patchPath, '..')
213
+ const base = basename(patchPath)
214
+ const names = await readdir(dir).catch(() => [])
215
+ return names.filter((n) => n.startsWith(base + '.bak-')).sort().reverse()[0]
216
+ }
217
+
121
218
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
122
219
  export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)): RpcHandlerMap {
123
220
  return {
@@ -126,7 +223,16 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
126
223
  if ('err' in sc) return { ok: false, error: sc.err }
127
224
  const bucket = await bucketOf(registry, ctx, sc.cwd)
128
225
  const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null
129
- if (!want && args.skipStale !== true) await autoArchiveStale(ctx, sc.session, sc.cwd, bucket) // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过)
226
+ // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
227
+ // 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
228
+ // 避免每轮都对全部记录读文件 + 算指纹(keepall 期间多组件同时刷新的主要 host 开销)。
229
+ if (!want && args.skipStale !== true) {
230
+ const now = Date.now()
231
+ if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
232
+ staleCheckedAt.set(sc.cwd, now)
233
+ await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
234
+ }
235
+ }
130
236
  const out: RecordView[] = []
131
237
  for (const rec of bucket.values()) {
132
238
  // 面板全量查询过滤已归档;聊天条按 callId 查询保留(状态徽章仍需正确显示)
@@ -211,6 +317,8 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
211
317
  try {
212
318
  const target = await resolveTarget(ctx, sc.session, args.path)
213
319
  await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session))
320
+ // 手动保存后目录树可能变化(新建/删除文件):父目录+祖先进失效,后台自愈。
321
+ invalidateIndex(ctx, sc.cwd, args.path)
214
322
  const bucket = await bucketOf(registry, ctx, sc.cwd)
215
323
  let changed = false
216
324
  for (const rec of bucket.values()) {
@@ -293,26 +401,12 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
293
401
  return { ok: true, path: args.path, batch: batch ?? null }
294
402
  },
295
403
  'edrv.debug': async (args) => {
296
- // 诊断日志:client 上报 → 写入工作区旁车 .dsh-edit-review-debug.log(console 不一定落盘,文件可靠)
404
+ // 诊断日志:client 上报 → 内存缓冲批量落盘 .dsh-edit-review-debug.log(console 不一定落盘,文件可靠)。
405
+ // 只出现在调试开关开启时(client dbg 默认关),终端仍逐条打印便于实时观察。
297
406
  const sc = await requireSession(ctx, args.sessionId)
298
407
  if ('err' in sc) return { ok: false, error: sc.err }
299
- const fs = ctx.get('fs')
300
408
  const text = String(args.text ?? '')
301
- const prev = debugWriteQueues.get(sc.cwd) || Promise.resolve()
302
- const task = prev.then(async () => {
303
- try {
304
- const target = await fs.resolve('.dsh-edit-review-debug.log', { cwd: sc.cwd })
305
- const old = await fs.readText(target).catch(() => '')
306
- const line = new Date().toISOString() + ' ' + text + '\n'
307
- // 超上限时截断保留尾部:单文件体积有界,每次追加的读改写成本不随会话时长增长
308
- const next = old.length + line.length > DEBUG_LOG_CAP
309
- ? old.slice(-Math.floor(DEBUG_LOG_CAP / 2)) + line
310
- : old + line
311
- await fs.writeText(target, next, void 0, void 0, policyOf(ctx, sc.session))
312
- } catch (e) { /* 写日志失败忽略 */ }
313
- })
314
- debugWriteQueues.set(sc.cwd, task)
315
- await task
409
+ enqueueDebug(ctx, sc.cwd, text)
316
410
  console.error('[edrv-debug] ' + text)
317
411
  return { ok: true }
318
412
  },
@@ -347,24 +441,15 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
347
441
  }
348
442
  },
349
443
  'edrv.listDir': async (args) => {
350
- // 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
444
+ // 目录树(侧边栏文件管理用):树索引命中直出(内存,0 IO);失效/force 时
445
+ // 走快路径(resolve + listDirCheap + putIndex);同路径在途请求去重复用。
351
446
  const sc = await requireSession(ctx, args.sessionId)
352
447
  if ('err' in sc) return { ok: false, error: sc.err }
353
- const fs = ctx.get('fs')
354
- if (!fs) return { ok: false, error: '缺少 fs' }
355
- try {
356
- const rel = normalizeRel(args.path)
357
- if (rel === null) return { ok: false, error: '路径不合法' }
358
- const target = await resolveTarget(ctx, sc.session, rel || '.')
359
- const info = await fs.stat(target)
360
- if (!info) return { ok: false, error: '目录不存在' }
361
- if (info.type !== 'directory') return { ok: false, error: '不是目录' }
362
- const children = await fs.listDir(target)
363
- const root = fs.processPath(await fs.resolve('.', { cwd: sc.cwd }))
364
- return { ok: true, root, path: rel, entries: toTreeEntries(rel, children) }
365
- } catch (error) {
366
- return { ok: false, error: '读取目录失败:' + String(error) }
367
- }
448
+ const rel = normalizeRel(args.path)
449
+ if (rel === null) return { ok: false, error: '路径不合法' }
450
+ const res = await listDirCached(ctx, sc.cwd, rel, args.force === true)
451
+ if ('error' in res) return { ok: false, error: res.error }
452
+ return { ok: true, root: res.root, path: rel, entries: res.entries }
368
453
  },
369
454
  'edrv.revealInExplorer': async (args) => {
370
455
  // 在 OS 文件浏览器中打开/定位路径(树行/编辑器右键菜单用),相对工作区解析。
@@ -451,6 +536,120 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
451
536
  return { ok: false, error: String(error) }
452
537
  }
453
538
  },
539
+ 'edrv.perf.inventory': async () => {
540
+ try {
541
+ const home = dshHome()
542
+ const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
543
+ const active = activeSessionIds(ctx)
544
+ markActiveSessions(inventory.sessions, active)
545
+ return { ok: true, ...inventory, activeIds: [...active] }
546
+ } catch (error) {
547
+ return { ok: false, error: '盘点失败:' + String(error) }
548
+ }
549
+ },
550
+ 'edrv.perf.sessionSize': async (args) => {
551
+ try {
552
+ const size = await sessionSizeOf(dshHome(), args.cwd, args.sessionId ?? '')
553
+ return { ok: true, ...size }
554
+ } catch (error) {
555
+ return { ok: false, error: '读取会话体积失败:' + String(error) }
556
+ }
557
+ },
558
+ 'edrv.perf.movePlan': async (args) => {
559
+ try {
560
+ const home = dshHome()
561
+ const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home))
562
+ markActiveSessions(inventory.sessions, activeSessionIds(ctx))
563
+ const plan = planMoveOut(inventory, { workspaceKey: args.workspaceKey, sessionIds: args.sessionIds, minBytes: args.minBytes, olderThanDays: args.olderThanDays })
564
+ return { ok: true, ...plan }
565
+ } catch (error) {
566
+ return { ok: false, error: '移出规划失败:' + String(error) }
567
+ }
568
+ },
569
+ 'edrv.perf.moveOut': async (args) => {
570
+ try {
571
+ const home = dshHome()
572
+ const items = (args.sessionIds ?? []).map((sessionId) => ({ workspaceKey: args.workspaceKey, sessionId, bytes: 0 }))
573
+ const result = await moveOutSessions(home, sessionsArchiveRoot(home), items, activeSessionIds(ctx), args.dryRun === true)
574
+ return { ok: true, ...result }
575
+ } catch (error) {
576
+ return { ok: false, error: '移出失败:' + String(error) }
577
+ }
578
+ },
579
+ 'edrv.perf.restore': async (args) => {
580
+ try {
581
+ const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId)
582
+ return result.ok ? { ok: true, restored: true } : { ok: false, error: result.error ?? '恢复失败' }
583
+ } catch (error) {
584
+ return { ok: false, error: '恢复失败:' + String(error) }
585
+ }
586
+ },
587
+ 'edrv.perf.purgeArchive': async (args) => {
588
+ try {
589
+ const result = await purgeArchive(sessionsArchiveRoot(dshHome()), args.olderThanDays)
590
+ return { ok: true, ...result }
591
+ } catch (error) {
592
+ return { ok: false, error: '清除失败:' + String(error) }
593
+ }
594
+ },
595
+ 'edrv.perf.sidecarSummary': async (args) => {
596
+ const sc = await requireSession(ctx, args.sessionId)
597
+ const cwd = 'err' in sc ? null : sc.cwd
598
+ try {
599
+ const summary = await sidecarSummaryOf(ctx, cwd)
600
+ return { ok: true, ...summary }
601
+ } catch (error) {
602
+ return { ok: false, error: '读取侧车摘要失败:' + String(error) }
603
+ }
604
+ },
605
+ 'edrv.perf.configGet': async () => {
606
+ try {
607
+ const { profileDir, patchPath } = await patchFileInfo()
608
+ const block = perfConfigBlock()
609
+ if (!patchPath) return { ok: true, applied: false, block }
610
+ const text = await readFile(patchPath, 'utf8').catch(() => '')
611
+ const backup = await latestPatchBackup(patchPath)
612
+ return { ok: true, profileDir, patchPath, applied: patchHasPerfConfig(text), block, backup }
613
+ } catch (error) {
614
+ return { ok: false, error: '读取压缩配置失败:' + String(error) }
615
+ }
616
+ },
617
+ 'edrv.perf.configApply': async () => {
618
+ try {
619
+ const { patchPath } = await patchFileInfo()
620
+ if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
621
+ const text = await readFile(patchPath, 'utf8').catch(() => '')
622
+ const backup = patchPath + '.bak-' + Date.now()
623
+ if (text) await writeFile(backup, text, 'utf8')
624
+ await mkdir(join(patchPath, '..'), { recursive: true })
625
+ await writeFile(patchPath, patchInsertPerfConfig(text), 'utf8')
626
+ return { ok: true, applied: true, backup, restart: true }
627
+ } catch (error) {
628
+ return { ok: false, error: '写入压缩配置失败:' + String(error) }
629
+ }
630
+ },
631
+ 'edrv.perf.configUndo': async () => {
632
+ try {
633
+ const { patchPath } = await patchFileInfo()
634
+ if (!patchPath) return { ok: false, error: '未找到依赖本插件的 profile(检查 DSH_HOME/profiles)' }
635
+ const backup = await latestPatchBackup(patchPath)
636
+ if (backup) {
637
+ const backupPath = join(join(patchPath, '..'), backup)
638
+ const saved = await readFile(backupPath, 'utf8').catch(() => null)
639
+ if (saved !== null) {
640
+ await writeFile(patchPath, saved, 'utf8')
641
+ await rm(backupPath, { force: true }).catch(() => {})
642
+ return { ok: true, restored: true, backup }
643
+ }
644
+ }
645
+ const text = await readFile(patchPath, 'utf8').catch(() => '')
646
+ if (!patchHasPerfConfig(text)) return { ok: false, error: '没有可撤销的压缩配置' }
647
+ await writeFile(patchPath, patchRemovePerfConfig(text), 'utf8')
648
+ return { ok: true, restored: true }
649
+ } catch (error) {
650
+ return { ok: false, error: '撤销压缩配置失败:' + String(error) }
651
+ }
652
+ },
454
653
  }
455
654
  }
456
655
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { Ctx, Session } from '../store.js'
8
8
  import { pathText } from './query.js'
9
- import { EXCLUDES, FILE_EXCLUDES, ripgrepPath, searchRoot } from './ripgrep.js'
9
+ import { EXCLUDES, FILE_EXCLUDES, readStderr, rgExitFailure, ripgrepPath, searchRoot } from './ripgrep.js'
10
10
  import { SearchCache } from './orchestrator.js'
11
11
  import type { ContentMatch, ContentSearchInput, ContentSearchProvider, ContentSearchResult } from './types.js'
12
12
 
@@ -47,7 +47,7 @@ export interface ContentSearchRequest {
47
47
  exclude?: string[]
48
48
  }
49
49
 
50
- type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean }
50
+ type ContentSearchResponse = { matches: ContentMatch[]; truncated: boolean; warning?: string }
51
51
 
52
52
  /**
53
53
  * 把行内字节偏移转成 UTF-16 列(1-based;越界钳到行尾)。
@@ -181,7 +181,7 @@ export function contentArgv(binary: string, root: string, input: ContentSearchIn
181
181
  * @returns 有界内容搜索结果
182
182
  */
183
183
  export async function searchRipgrepContent(input: ContentSearchInput): Promise<ContentSearchResult> {
184
- const sub = input.ctx.get('subprocess') as { spawn(spec: unknown): { done: Promise<{ exitCode: number | null; code?: number | null }>; collected?: { stdout?: { readFrom(offset: number): { text: string; lossy?: boolean } } } } } | undefined
184
+ const sub = input.ctx.get('subprocess') as { spawn(spec: unknown): { done: Promise<{ exitCode: number | null; code?: number | null }>; collected?: { stdout?: { readFrom(offset: number): { text: string; lossy?: boolean } }; stderr?: { readFrom(offset: number): { text: string } } } } } | undefined
185
185
  if (!sub) throw new Error('缺少 subprocess')
186
186
  const binary = ripgrepPath()
187
187
  if (!binary) throw new Error('ripgrep 不可用')
@@ -200,7 +200,8 @@ export async function searchRipgrepContent(input: ContentSearchInput): Promise<C
200
200
  throw new Error('ripgrep 启动失败:' + String(error))
201
201
  }
202
202
  const code = outcome.exitCode ?? outcome.code
203
- if (code !== 0 && code !== 1) throw new Error('ripgrep 退出码:' + String(code))
203
+ const failure = code !== 0 && code !== 1 ? rgExitFailure(code, readStderr(handle).text) : null
204
+ if (failure && failure.kind !== 'partial') throw new Error(failure.message)
204
205
  const reader = handle.collected?.stdout
205
206
  if (!reader) throw new Error('ripgrep stdout 不可用')
206
207
  const output = reader.readFrom(0)
@@ -208,7 +209,13 @@ export async function searchRipgrepContent(input: ContentSearchInput): Promise<C
208
209
  const maxMatches = Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES))
209
210
  const capped = applyCaps(all, maxMatches, DEFAULT_MAX_FILES)
210
211
  const truncated = Boolean(output.lossy) || capped.truncated
211
- return { ...capped, complete: !truncated, source: 'ripgrep' }
212
+ const result: ContentSearchResult = { ...capped, complete: !truncated, source: 'ripgrep' }
213
+ // 遍历错误(退出码 2):rg 已输出的命中仍有效,保留结果、标记不完整并附部分结果提示
214
+ if (failure) {
215
+ result.complete = false
216
+ result.warning = failure.message
217
+ }
218
+ return result
212
219
  }
213
220
 
214
221
  /**
@@ -251,7 +258,7 @@ export class ContentSearcher {
251
258
  const excludeKey = (request.exclude ?? []).join(',')
252
259
  const key = [rootKey, query, request.matchCase ? 'mc' : '', request.wholeWord ? 'ww' : '', request.regex ? 'rx' : '', includeKey, excludeKey, PROVIDER_VERSION].join('|')
253
260
  const cached = this.cache.get(key)
254
- if (cached) return { matches: cached.matches, truncated: cached.truncated }
261
+ if (cached) return { matches: cached.matches, truncated: cached.truncated, warning: cached.warning }
255
262
  this.inflight.get(root)?.abort()
256
263
  const controller = new AbortController()
257
264
  this.inflight.set(root, controller)
@@ -272,7 +279,7 @@ export class ContentSearcher {
272
279
  })
273
280
  if (controller.signal.aborted) return { matches: [], truncated: false }
274
281
  this.cache.set(key, result)
275
- return { matches: result.matches, truncated: result.truncated }
282
+ return { matches: result.matches, truncated: result.truncated, warning: result.warning }
276
283
  } catch (error) {
277
284
  if (controller.signal.aborted) return { matches: [], truncated: false }
278
285
  throw error
@@ -112,6 +112,53 @@ export function parseOutput(handle: SearchHandle, maxResults: number): { files:
112
112
  return { files: unique.slice(0, maxResults).map(pathText), truncated, complete: !truncated }
113
113
  }
114
114
 
115
+ /**
116
+ * 读取已收集的 stderr(无收集器 → 空文本)。
117
+ * @author ddj 2026年08月27号
118
+ * @param handle subprocess handle
119
+ * @returns stderr 文本
120
+ */
121
+ export function readStderr(handle: SearchHandle): { text: string } {
122
+ const reader = handle.collected?.stderr
123
+ return reader ? reader.readFrom(0) : { text: '' }
124
+ }
125
+
126
+ /**
127
+ * 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
128
+ * @author ddj 2026年08月27号
129
+ * @param text stderr 全文
130
+ * @returns 首条诊断行(无则空串)
131
+ */
132
+ export function firstStderrLine(text: string): string {
133
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
134
+ const line = raw.trim()
135
+ if (!line) continue
136
+ return line.length > 300 ? line.slice(0, 300) + '…' : line
137
+ }
138
+ return ''
139
+ }
140
+
141
+ /**
142
+ * 分类 rg 非 0/1 退出:pattern=模式错误(正则/glob 无效,无结果),
143
+ * partial=遍历错误(rg 已输出的命中仍有效,结果不完整),hard=硬失败(其他退出码/超时被杀)。
144
+ * @author ddj 2026年08月27号
145
+ * @param code 退出码(null/undefined = 被终止,通常为 20s 超时)
146
+ * @param stderrText stderr 全文
147
+ * @returns 分类与中文提示文案(partial 的 message 即 warning 内容)
148
+ */
149
+ export function rgExitFailure(code: number | null | undefined, stderrText: string): { kind: 'pattern' | 'partial' | 'hard'; message: string } {
150
+ const detail = firstStderrLine(stderrText)
151
+ if (/regex parse error/i.test(detail)) return { kind: 'pattern', message: '正则表达式无效:' + detail }
152
+ if (/error parsing glob/i.test(detail)) return { kind: 'pattern', message: '文件过滤模式无效:' + detail }
153
+ if (code === 2) {
154
+ const count = String(stderrText ?? '').split(/\r?\n/).filter((line) => line.trim()).length
155
+ const suffix = count > 1 ? '(共 ' + count + ' 处)' : ''
156
+ return { kind: 'partial', message: (detail ? '部分路径无法访问,结果可能不完整:' + detail + suffix : '部分路径无法访问,结果可能不完整') }
157
+ }
158
+ if (code === null || code === undefined) return { kind: 'hard', message: '搜索超时(20 秒内未完成,已终止)' }
159
+ return { kind: 'hard', message: 'ripgrep 退出码:' + String(code) + (detail ? '(' + detail + ')' : '') }
160
+ }
161
+
115
162
  /**
116
163
  * 使用打包 ripgrep 搜索工作区文件。
117
164
  * @author ddj 2026年08月24号
@@ -143,7 +190,13 @@ export async function searchRipgrep(input: WorkspaceSearchInput): Promise<Worksp
143
190
  throw new Error('ripgrep 启动失败:' + String(error))
144
191
  }
145
192
  const code = outcome.exitCode ?? outcome.code
146
- if (code !== 0 && code !== 1) throw new Error('ripgrep 退出码:' + String(code))
193
+ if (code !== 0 && code !== 1) {
194
+ const failure = rgExitFailure(code, readStderr(handle).text)
195
+ if (failure.kind !== 'partial') throw new Error(failure.message)
196
+ // 遍历错误:已收集的文件列表仍有效,保留并标记不完整(warning 供上层展示,编排层丢弃不抛错)
197
+ const parsed = parseOutput(handle, input.maxResults)
198
+ return { ...parsed, warning: failure.message, complete: false, source: 'ripgrep' }
199
+ }
147
200
  const parsed = parseOutput(handle, input.maxResults)
148
201
  if (!handle.collected?.stdout) throw new Error('ripgrep stdout 不可用')
149
202
  return { ...parsed, source: 'ripgrep' }
@@ -22,6 +22,8 @@ export interface WorkspaceSearchResult {
22
22
  truncated: boolean
23
23
  complete: boolean
24
24
  source: SearchSource
25
+ /** rg 遍历错误(退出码 2)时的部分结果提示;无则缺省。 */
26
+ warning?: string
25
27
  }
26
28
 
27
29
  export interface WorkspaceSearchProvider {
@@ -73,12 +75,13 @@ export interface ContentSearchInput {
73
75
  root?: string
74
76
  }
75
77
 
76
- /** 内容搜索输出:扁平命中列表 + 截断标志。 */
78
+ /** 内容搜索输出:扁平命中列表 + 截断标志(warning = rg 遍历错误时的部分结果提示)。 */
77
79
  export interface ContentSearchResult {
78
80
  matches: ContentMatch[]
79
81
  truncated: boolean
80
82
  complete: boolean
81
83
  source: SearchSource
84
+ warning?: string
82
85
  }
83
86
 
84
87
  /** 内容搜索 provider(rg JSON 主路径;无 fallback,失败抛错由调用方处理)。 */