dsh-vscode-mode 0.1.30 → 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,9 +32,13 @@ 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
43
  /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
38
44
  const debugBuffers = new Map<string, { lines: string[]; len: number; timer: ReturnType<typeof setTimeout> | null }>()
@@ -52,13 +58,12 @@ const STALE_CHECK_MIN_MS = 10_000
52
58
  /**
53
59
  * 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
54
60
  * 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
55
- * @author ddj 2026年08月26号
61
+ * @author ddj 2026年08月26号 / 2026年09月01号
56
62
  * @param ctx DSH 上下文
57
- * @param cwd 工作区(日志文件按工作区旁车存放)
58
- * @param policy 会话沙箱策略(入队时刻捕获)
63
+ * @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
59
64
  * @param line 单条日志文本(不含换行)
60
65
  */
61
- function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): void {
66
+ function enqueueDebug(ctx: Ctx, cwd: string, line: string): void {
62
67
  const st = debugBuffers.get(cwd) ?? { lines: [], len: 0, timer: null }
63
68
  st.lines.push(line)
64
69
  st.len += line.length
@@ -67,7 +72,7 @@ function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): voi
67
72
  const batch = st.lines
68
73
  st.lines = []
69
74
  st.len = 0
70
- void flushDebug(ctx, cwd, policy, batch)
75
+ void flushDebug(cwd, batch)
71
76
  }
72
77
  if (st.len >= DEBUG_BUF_CAP) {
73
78
  if (st.timer) clearTimeout(st.timer)
@@ -78,26 +83,31 @@ function enqueueDebug(ctx: Ctx, cwd: string, policy: unknown, line: string): voi
78
83
  debugBuffers.set(cwd, st)
79
84
  }
80
85
 
86
+ /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
87
+ const debugLegacyCleaned = new Set<string>()
88
+
81
89
  /**
82
90
  * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
83
91
  * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
84
- * @author ddj 2026年08月26号
85
- * @param ctx DSH 上下文
86
- * @param cwd 工作区
87
- * @param policy 会话沙箱策略(enqueue 时刻捕获)
92
+ * @author ddj 2026年08月26号 / 2026年09月01号
93
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
88
94
  * @param batch 本批日志行
89
95
  */
90
- function flushDebug(ctx: Ctx, cwd: string, policy: unknown, batch: string[]): Promise<void> {
96
+ function flushDebug(cwd: string, batch: string[]): Promise<void> {
91
97
  const prev = debugWriteQueues.get(cwd) ?? Promise.resolve()
92
98
  const task = prev.then(async () => {
93
99
  try {
94
- const fs = ctx.get('fs')
95
- if (!fs) return
96
- const target = await fs.resolve('.dsh-edit-review-debug.log', { cwd })
97
- const old = await fs.readText(target).catch(() => '')
100
+ await mkdir(pluginLogRoot(), { recursive: true })
101
+ const target = debugLogFile(cwd)
102
+ const old = await readFile(target, 'utf8').catch(() => '')
98
103
  const appended = old + batch.map((l) => new Date().toISOString() + ' ' + l + '\n').join('')
99
104
  const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended
100
- await fs.writeText(target, next, void 0, void 0, policy)
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
+ }
101
111
  } catch (e) { /* 写日志失败忽略 */ }
102
112
  })
103
113
  debugWriteQueues.set(cwd, task)
@@ -183,6 +193,28 @@ async function applyDecisions(
183
193
  return results
184
194
  }
185
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
+
186
218
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
187
219
  export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)): RpcHandlerMap {
188
220
  return {
@@ -285,6 +317,8 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
285
317
  try {
286
318
  const target = await resolveTarget(ctx, sc.session, args.path)
287
319
  await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session))
320
+ // 手动保存后目录树可能变化(新建/删除文件):父目录+祖先进失效,后台自愈。
321
+ invalidateIndex(ctx, sc.cwd, args.path)
288
322
  const bucket = await bucketOf(registry, ctx, sc.cwd)
289
323
  let changed = false
290
324
  for (const rec of bucket.values()) {
@@ -372,7 +406,7 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
372
406
  const sc = await requireSession(ctx, args.sessionId)
373
407
  if ('err' in sc) return { ok: false, error: sc.err }
374
408
  const text = String(args.text ?? '')
375
- enqueueDebug(ctx, sc.cwd, policyOf(ctx, sc.session), text)
409
+ enqueueDebug(ctx, sc.cwd, text)
376
410
  console.error('[edrv-debug] ' + text)
377
411
  return { ok: true }
378
412
  },
@@ -407,24 +441,15 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
407
441
  }
408
442
  },
409
443
  'edrv.listDir': async (args) => {
410
- // 目录树(侧边栏文件管理用):按会话 cwd 解析,与 edrv.read 同基准,树内相对路径点开即打开。
444
+ // 目录树(侧边栏文件管理用):树索引命中直出(内存,0 IO);失效/force 时
445
+ // 走快路径(resolve + listDirCheap + putIndex);同路径在途请求去重复用。
411
446
  const sc = await requireSession(ctx, args.sessionId)
412
447
  if ('err' in sc) return { ok: false, error: sc.err }
413
- const fs = ctx.get('fs')
414
- if (!fs) return { ok: false, error: '缺少 fs' }
415
- try {
416
- const rel = normalizeRel(args.path)
417
- if (rel === null) return { ok: false, error: '路径不合法' }
418
- const target = await resolveTarget(ctx, sc.session, rel || '.')
419
- const info = await fs.stat(target)
420
- if (!info) return { ok: false, error: '目录不存在' }
421
- if (info.type !== 'directory') return { ok: false, error: '不是目录' }
422
- const children = await fs.listDir(target)
423
- const root = fs.processPath(await fs.resolve('.', { cwd: sc.cwd }))
424
- return { ok: true, root, path: rel, entries: toTreeEntries(rel, children) }
425
- } catch (error) {
426
- return { ok: false, error: '读取目录失败:' + String(error) }
427
- }
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 }
428
453
  },
429
454
  'edrv.revealInExplorer': async (args) => {
430
455
  // 在 OS 文件浏览器中打开/定位路径(树行/编辑器右键菜单用),相对工作区解析。
@@ -511,6 +536,120 @@ export function buildHandlers(ctx: Ctx, registry: Registry, searcher = newSearch
511
536
  return { ok: false, error: String(error) }
512
537
  }
513
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
+ },
514
653
  }
515
654
  }
516
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,失败抛错由调用方处理)。 */