dsh-vscode-mode 0.8.0 → 0.9.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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * dsh-vscode-mode host — dsh-remote-ssh 远程工作区探测与镜像↔远端路径映射。
3
+ * 背景(issue #7):dsh-remote-ssh 的「原生远程工作区」cwd 指向本机镜像目录
4
+ * (如 ~/.dsh/remote-workspaces/<id>,根下含 .remote-ssh.json {profileId, host,
5
+ * user, remotePath}),agent 的 remote_ssh_write 直写远端;差异审查需要把
6
+ * 远端参数路径映射回镜像(捕获/读/决策复用本地链路),并把展示路径翻译成远端形态。
7
+ * 判据与上游 dsh-remote-ssh 的 findMirrorRoot 对齐(host/user/remotePath 必填、
8
+ * 向上 ≤8 层有界);纯函数为主,node:fs 仅用于标记读取(带 60s TTL 缓存)。
9
+ * 作者 ddj 2026年09月23号
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+
14
+ // --region 常量与类型
15
+ /** dsh-remote-ssh 工作区标记文件名。 */
16
+ const MARKER = '.remote-ssh.json'
17
+ /** 向上探测的最大层数(含 cwd 自身;与上游 findMirrorRoot 的 8 层有界一致)。 */
18
+ const MAX_DEPTH = 8
19
+ /** 探测结果 TTL(edrv.list 为 5s 轮询,避免每轮都做文件 IO)。 */
20
+ const TTL_MS = 60_000
21
+
22
+ /** 远程工作区(镜像根 + 标记内容)。 */
23
+ export interface RemoteWs {
24
+ /** 镜像根(含标记文件的目录,平台绝对路径)。 */
25
+ root: string
26
+ host: string
27
+ user: string
28
+ /** 远端工作区目录(`~/proj` 或绝对路径,原样保留)。 */
29
+ remotePath: string
30
+ profileId?: string
31
+ }
32
+ // --endregion
33
+
34
+ // --region 探测
35
+ /** cwd → { at, ws } 探测缓存(含阴性结果)。 */
36
+ const cache = new Map<string, { at: number; ws: RemoteWs | null }>()
37
+
38
+ /**
39
+ * 标记内容 → 远程工作区(host/user/remotePath 缺任一字段判非法,与上游一致)。
40
+ * @author ddj 2026年09月23号
41
+ * @param root 标记所在目录
42
+ * @param raw 标记文件文本
43
+ * @returns 合法返回 RemoteWs;非法返回 null
44
+ */
45
+ function markerWsOf(root: string, raw: string): RemoteWs | null {
46
+ try {
47
+ const info = JSON.parse(raw) as Record<string, unknown>
48
+ if (typeof info.host !== 'string' || !info.host) return null
49
+ if (typeof info.user !== 'string' || !info.user) return null
50
+ if (typeof info.remotePath !== 'string' || !info.remotePath) return null
51
+ const ws: RemoteWs = { root, host: info.host, user: info.user, remotePath: info.remotePath }
52
+ if (typeof info.profileId === 'string' && info.profileId) ws.profileId = info.profileId
53
+ return ws
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 从 cwd 起向上有界探测标记(不走缓存)。
61
+ * @author ddj 2026年09月23号
62
+ * @param cwd 会话工作区根
63
+ * @returns 命中合法标记返回 RemoteWs;否则 null
64
+ */
65
+ function walkRemoteWs(cwd: string): RemoteWs | null {
66
+ let dir = cwd
67
+ for (let i = 0; i < MAX_DEPTH; i++) {
68
+ try {
69
+ const ws = markerWsOf(dir, readFileSync(join(dir, MARKER), 'utf8'))
70
+ if (ws) return ws
71
+ } catch {
72
+ // 无标记或不可读/JSON 损坏:继续向上(与上游 findMirrorRoot 行为一致)
73
+ }
74
+ const parent = dirname(dir)
75
+ if (parent === dir) break
76
+ dir = parent
77
+ }
78
+ return null
79
+ }
80
+
81
+ /**
82
+ * 探测工作区是否为 dsh-remote-ssh 远程工作区(cwd 或向上 ≤8 层找 .remote-ssh.json)。
83
+ * 结果按 cwd 缓存 60s(含阴性);标记变更后可用 clearWsCache 立即重置。
84
+ * @author ddj 2026年09月23号
85
+ * @param cwd 会话工作区根(可空)
86
+ * @returns 远程工作区信息;非远程或标记非法返回 null
87
+ */
88
+ export function findRemoteWs(cwd: string | null | undefined): RemoteWs | null {
89
+ if (!cwd) return null
90
+ const now = Date.now()
91
+ const hit = cache.get(cwd)
92
+ if (hit && now - hit.at < TTL_MS) return hit.ws
93
+ const ws = walkRemoteWs(cwd)
94
+ cache.set(cwd, { at: now, ws })
95
+ return ws
96
+ }
97
+
98
+ /**
99
+ * 清空探测缓存(测试与标记变更后重置用)。
100
+ * @author ddj 2026年09月23号
101
+ */
102
+ export function clearWsCache(): void {
103
+ cache.clear()
104
+ }
105
+ // --endregion
106
+
107
+ // --region 路径映射
108
+ /**
109
+ * 前缀剥离:path 必须严格位于 root 之下;等于 root 或不匹配返回 null。
110
+ * @author ddj 2026年09月23号
111
+ * @param path 待剥离路径(posix 形态)
112
+ * @param root 前缀(posix 形态、无尾斜杠)
113
+ * @returns 相对段;不匹配返回 null
114
+ */
115
+ function prefixRel(path: string, root: string): string | null {
116
+ if (path === root || !path.startsWith(root + '/')) return null
117
+ return path.slice(root.length + 1)
118
+ }
119
+
120
+ /**
121
+ * 远端参数路径 → 相对远端工作区目录的相对段。
122
+ * 支持:相对路径(工具语义即基于远端工作区目录)、绝对路径且 remotePath 为绝对
123
+ * 形态并为其前缀、`~` 形态且 remotePath 同为 `~` 前缀;Windows 盘符形态按
124
+ * 非远端语义拒绝。含 `..` 逃逸或空相对段返回 null。
125
+ * @author ddj 2026年09月23号
126
+ * @param argPath remote_ssh_write 的 path 参数
127
+ * @param ws 远程工作区
128
+ * @returns posix 相对段;不可映射返回 null
129
+ */
130
+ function remoteRelOf(argPath: string, ws: RemoteWs): string | null {
131
+ const path = argPath.replace(/\\/g, '/').replace(/^\.\//, '')
132
+ if (!path) return null
133
+ const root = ws.remotePath.replace(/\\/g, '/').replace(/\/+$/, '')
134
+ if (!root) return null
135
+ let rel: string | null
136
+ if (path.startsWith('/')) {
137
+ rel = root.startsWith('/') ? prefixRel(path, root) : null
138
+ } else if (path === '~' || path.startsWith('~/')) {
139
+ rel = root.startsWith('~/') ? prefixRel(path, root) : null
140
+ } else if (/^[A-Za-z]:\//.test(path)) {
141
+ rel = null
142
+ } else {
143
+ rel = path
144
+ }
145
+ if (rel === null || rel.split('/').some((seg) => seg === '..')) return null
146
+ return rel
147
+ }
148
+
149
+ /**
150
+ * 远端工具参数路径 → 本机镜像绝对路径。
151
+ * @author ddj 2026年09月23号
152
+ * @param argPath remote_ssh_write 的 path 参数
153
+ * @param ws 远程工作区
154
+ * @returns 镜像内绝对路径;镜像外/前缀形态不一致/逃逸返回 null
155
+ */
156
+ export function mirrorTargetOf(argPath: string, ws: RemoteWs): string | null {
157
+ const rel = remoteRelOf(argPath, ws)
158
+ return rel === null ? null : join(ws.root, rel)
159
+ }
160
+
161
+ /**
162
+ * 镜像内绝对路径 → 远端展示路径(remotePath + 相对段,posix 拼接)。
163
+ * @author ddj 2026年09月23号
164
+ * @param mirrorAbs 镜像内绝对路径(通常是记录 path)
165
+ * @param ws 远程工作区
166
+ * @returns 远端路径;不在镜像根下返回 null(调用方回落原 path)
167
+ */
168
+ export function remoteDisplayOf(mirrorAbs: string, ws: RemoteWs): string | null {
169
+ const path = mirrorAbs.replace(/\\/g, '/')
170
+ const root = ws.root.replace(/\\/g, '/').replace(/\/+$/, '')
171
+ if (!root) return null
172
+ const rel = prefixRel(path, root)
173
+ if (rel === null || rel.split('/').some((seg) => seg === '..')) return null
174
+ const remoteRoot = ws.remotePath.replace(/\/+$/, '')
175
+ return remoteRoot + '/' + rel
176
+ }
177
+ // --endregion
package/src/rpc.ts CHANGED
@@ -44,6 +44,7 @@ import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
44
44
  import { normalizeRel } from './tree.js'
45
45
  import { baseNameOf, checkNewName, checkRenameName, isSubPath, joinRelPath, parentRelOf } from './shared/fsNames.js'
46
46
  import { invalidateIndex, listDirCached } from './treeIndex.js'
47
+ import { findRemoteWs, remoteDisplayOf, type RemoteWs } from './remoteWorkspace.js'
47
48
  import { revealInExplorer } from './reveal.js'
48
49
  import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
49
50
  import { clearDebugLog, debugRecord, isDebugLogName, listDebugLogs, readDebugLog } from './debugLog.js'
@@ -56,8 +57,8 @@ const staleCheckedAt = new Map<string, number>()
56
57
  /** stale 自动清理最小间隔。 */
57
58
  const STALE_CHECK_MIN_MS = 10_000
58
59
 
59
- /** 记录 → 客户端视图(不含 before 全文,仅长度)。 */
60
- function recView(record: DiffRecord): RecordView {
60
+ /** 记录 → 客户端视图(不含 before 全文,仅长度);远程工作区附远端展示路径(issue #7)。 */
61
+ function recView(record: DiffRecord, ws: RemoteWs | null = null): RecordView {
61
62
  return {
62
63
  callId: record.callId,
63
64
  toolName: record.toolName,
@@ -75,6 +76,7 @@ function recView(record: DiffRecord): RecordView {
75
76
  conflict: record.conflict === true,
76
77
  legacy: record.legacy === true,
77
78
  at: record.at,
79
+ ...(ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? undefined } : {}),
78
80
  }
79
81
  }
80
82
 
@@ -232,6 +234,7 @@ async function applyDecisions(
232
234
  ): Promise<DecideResult[]> {
233
235
  const results: DecideResult[] = []
234
236
  const resolved: DiffRecord[] = []
237
+ const ws = findRemoteWs(cwd)
235
238
  let changed = false
236
239
  for (const item of items) {
237
240
  const record = bucket.get(item.callId)
@@ -255,7 +258,7 @@ async function applyDecisions(
255
258
  markDecision(record, item.scope ?? 'call', item.hunkIndex, item.decision)
256
259
  changed = true
257
260
  if (!wasResolved && recordResolved(record)) resolved.push(record)
258
- const result: DecideResult = { callId: item.callId, ok: true, record: recView(record) }
261
+ const result: DecideResult = { callId: item.callId, ok: true, record: recView(record, ws) }
259
262
  if (revertedStale) result.stale = true
260
263
  results.push(result)
261
264
  }
@@ -434,11 +437,12 @@ export function buildHandlers(
434
437
  }
435
438
  }
436
439
  const out: RecordView[] = []
440
+ const ws = findRemoteWs(sc.cwd)
437
441
  for (const rec of bucket.values()) {
438
442
  // 面板全量查询过滤已归档;聊天条按 callId 查询保留(状态徽章仍需正确显示)
439
443
  if (!want && rec.archived) continue
440
444
  if (want && !want.has(rec.callId)) continue
441
- out.push(recView(rec))
445
+ out.push(recView(rec, ws))
442
446
  }
443
447
  out.sort((a, b) => (a.at < b.at ? -1 : 1))
444
448
  return { ok: true, records: out }
@@ -610,6 +614,7 @@ export function buildHandlers(
610
614
  const sc = await requireSession(ctx, args.sessionId)
611
615
  if ('err' in sc) return { ok: false, error: sc.err }
612
616
  const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd)
617
+ const ws = findRemoteWs(sc.cwd)
613
618
  const entries = batches.map((b) => {
614
619
  const recs = Array.isArray(b.records) ? b.records : []
615
620
  const sum = recs.reduce((s, r) => {
@@ -620,7 +625,16 @@ export function buildHandlers(
620
625
  if (sm.superseded) s.superseded++
621
626
  return s
622
627
  }, { accepted: 0, rejected: 0, pending: 0, superseded: 0 })
623
- return { at: b.at, lastAt: b.lastAt || b.at, path: b.path, batch: b.batch ?? null, reason: b.reason ?? null, nRecords: recs.length, summary: sum }
628
+ return {
629
+ at: b.at,
630
+ lastAt: b.lastAt || b.at,
631
+ path: b.path,
632
+ ...(ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? undefined } : {}),
633
+ batch: b.batch ?? null,
634
+ reason: b.reason ?? null,
635
+ nRecords: recs.length,
636
+ summary: sum,
637
+ }
624
638
  })
625
639
  entries.sort((a, b) => (Number(b.batch ?? -1) - Number(a.batch ?? -1)) || (a.at < b.at ? 1 : -1))
626
640
  return { ok: true, entries }
package/src/shared/ai.ts CHANGED
@@ -66,6 +66,12 @@ export interface AiConfigView {
66
66
  model: string
67
67
  /** 空 = 跟随模型默认(请求不携带 reasoningEffort)。 */
68
68
  effort: string
69
+ /** AI 任务模型(非补全场景):空/缺省 = 跟随补全配置。 */
70
+ taskProvider?: string
71
+ /** AI 任务模型 id(与 taskProvider 成对;空/缺省 = 跟随补全配置)。 */
72
+ taskModel?: string
73
+ /** AI 任务模型思考档位(空/缺省 = 跟随补全配置的 effort)。 */
74
+ taskEffort?: string
69
75
  }
70
76
 
71
77
  /** AI 补全配置更新载荷(edrv.ai.configUpdate)。 */
@@ -75,6 +81,12 @@ export interface AiConfigPatch {
75
81
  model?: string
76
82
  /** 空 = 清除(跟随默认)。 */
77
83
  effort?: string
84
+ /** AI 任务模型(非补全场景):空 = 清除(跟随补全配置)。 */
85
+ taskProvider?: string
86
+ /** AI 任务模型 id(与 taskProvider 成对更新)。 */
87
+ taskModel?: string
88
+ /** AI 任务模型思考档位:空 = 清除(跟随补全配置的 effort)。 */
89
+ taskEffort?: string
78
90
  }
79
91
 
80
92
  /**
package/src/shared/rpc.ts CHANGED
@@ -19,7 +19,7 @@ import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules
19
19
  import type { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
20
20
  import type { LspCompletionItem, LspCompletionList, LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSignatureHelp, LspSymbol } from './lsp.js'
21
21
  import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
22
- import type { SvnAction, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdateResult } from './svn.js'
22
+ import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdateResult } from './svn.js'
23
23
  import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, DapConfigSnippet, DapConfigSource, DapDebugConfig, DapFrameView, DapPhase, DapPollResult, DapProcessInfo, DapScopeView, DapVariableView } from './dap.js'
24
24
  import { DAP_ACTIONS } from './dap.js'
25
25
  import type { LoggerLevel } from './logger.js'
@@ -439,6 +439,7 @@ export interface RpcRequestMap {
439
439
  'svn.diffBase': { sessionId?: string; path: string }
440
440
  'svn.revert': { sessionId?: string; paths: string[] }
441
441
  'svn.add': { sessionId?: string; paths: string[] }
442
+ 'svn.changelist': { sessionId?: string; paths: string[]; name?: string; remove?: boolean }
442
443
  'svn.log': { sessionId?: string; path?: string; limit?: number; stopOnCopy?: boolean; startRev?: number; endRev?: number; showMerged?: boolean }
443
444
  'svn.diffRev': { sessionId?: string; path: string; revision: number }
444
445
  'svn.diffPair': { sessionId?: string; path: string; revA: number; revB: number }
@@ -453,6 +454,14 @@ export interface RpcRequestMap {
453
454
  'svn.conflictArtifacts': { sessionId?: string; path: string }
454
455
  'svn.diffLocalPair': { sessionId?: string; left: string; right: string }
455
456
  'svn.fileSizes': { sessionId?: string; paths: string[] }
457
+ /** AI 智能整理:只读分析全部变更,产出分组/还原/忽略三段方案(不写工作副本)。 */
458
+ 'svn.aiPlan': { sessionId?: string }
459
+ /** AI 智能整理执行段:按目录合并写入 svn:ignore 属性(propget 合并,不整体覆盖)。 */
460
+ 'svn.ignore': { sessionId?: string; items: SvnIgnoreItem[] }
461
+ /** 混合通道投递口:会话 agent 深度分析后把方案 POST 回面板收件箱(免鉴权本地 RPC)。 */
462
+ 'svn.aiPlanSubmit': { sessionId?: string; plan: unknown }
463
+ /** 混合通道取件口:面板轮询取收件箱方案(since = 注入时刻,早于它的旧投递不算新件)。 */
464
+ 'svn.aiPlanPending': { sessionId?: string; since?: number }
456
465
  'edrv.dap.configs': { workspacePath: string }
457
466
  'edrv.dap.snippets': {}
458
467
  'edrv.dap.processes': { processName?: string }
@@ -578,6 +587,7 @@ export interface RpcOkMap {
578
587
  'svn.diffBase': { base: string | null; working: string; reason?: string; error?: string; binary?: boolean; encodingHint?: boolean }
579
588
  'svn.revert': { count: number; summary: string; output: string }
580
589
  'svn.add': { count: number; summary: string; output: string }
590
+ 'svn.changelist': { count: number; summary: string; output: string }
581
591
  'svn.log': { entries: SvnLogEntry[]; truncated: boolean; target: string; limit?: number }
582
592
  'svn.diffRev': SvnDiffRevResult
583
593
  'svn.diffPair': SvnDiffRevResult
@@ -592,6 +602,11 @@ export interface RpcOkMap {
592
602
  'svn.conflictArtifacts': { artifacts: SvnConflictArtifact[] }
593
603
  'svn.diffLocalPair': SvnDiffRevResult
594
604
  'svn.fileSizes': { sizes: Array<{ path: string; size: number | null }> }
605
+ 'svn.aiPlan': { plan: SvnAiPlan; entriesCount: number; diffIncluded: boolean; dropped: number; model?: string }
606
+ 'svn.ignore': { count: number; summary: string; output: string }
607
+ /** accepted = 归一后三段总路径数(0 = ok:false 拒收);dropped = 幻觉/落选丢弃数。 */
608
+ 'svn.aiPlanSubmit': { accepted: number; dropped: number }
609
+ 'svn.aiPlanPending': { plan: SvnAiPlan | null; dropped: number; at: number }
595
610
  'edrv.dap.configs': { configs: DapDebugConfig[]; adapters: DapAdapterInfo[]; source: DapConfigSource }
596
611
  'edrv.dap.snippets': { snippets: DapConfigSnippet[] }
597
612
  'edrv.dap.processes': { items: DapProcessInfo[] }
package/src/shared/svn.ts CHANGED
@@ -292,24 +292,39 @@ export interface SvnChangeFilter {
292
292
  name?: string
293
293
  }
294
294
 
295
+ /**
296
+ * 名称过滤匹配器(性能版):模式解析与正则编译只做一次,循环内零构造。
297
+ * `*` 任意串、`?` 单字符(忽略大小写);无通配符回落子串包含;
298
+ * 非法正则元字符已转义,永不抛错。与 changeNameMatch 语义一致。
299
+ * @author ddj 2026年09月23号
300
+ * @param pattern 过滤串(空/空白 = 全命中)
301
+ * @returns 单条路径命中判定
302
+ */
303
+ export function nameMatcherOf(pattern: string | undefined): (path: string) => boolean {
304
+ const query = String(pattern ?? '').trim().toLowerCase()
305
+ if (!query) return () => true
306
+ if (!query.includes('*') && !query.includes('?')) {
307
+ return (path) => String(path ?? '').toLowerCase().includes(query)
308
+ }
309
+ const source = query
310
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
311
+ .replaceAll('*', '.*')
312
+ .replaceAll('?', '.')
313
+ const tester = new RegExp('^' + source + '$')
314
+ return (path) => tester.test(String(path ?? '').toLowerCase())
315
+ }
316
+
295
317
  /**
296
318
  * 路径名称过滤匹配(W1-2):`*` 任意串、`?` 单字符(忽略大小写);
297
319
  * 无通配符时回落子串包含。非法正则元字符已转义,永不抛错。
320
+ * 大列表循环场景请改用 nameMatcherOf(预编译,避免逐条构造正则)。
298
321
  * @author ddj 2026年09月20号
299
322
  * @param path 条目路径(`/` 分隔)
300
323
  * @param pattern 过滤串(空/空白 = 不过滤)
301
324
  * @returns 是否命中
302
325
  */
303
326
  export function changeNameMatch(path: string, pattern: string | undefined): boolean {
304
- const query = String(pattern ?? '').trim().toLowerCase()
305
- if (!query) return true
306
- const target = String(path ?? '').toLowerCase()
307
- if (!query.includes('*') && !query.includes('?')) return target.includes(query)
308
- const source = query
309
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
310
- .replaceAll('*', '.*')
311
- .replaceAll('?', '.')
312
- return new RegExp('^' + source + '$').test(target)
327
+ return nameMatcherOf(pattern)(path)
313
328
  }
314
329
 
315
330
  /**
@@ -325,8 +340,10 @@ export function svnVisibleChanges(
325
340
  ): SvnChangeEntry[] {
326
341
  const showUnversioned = filter.unversioned !== false
327
342
  const showIgnored = filter.ignored === true
343
+ // 名称匹配器循环外编译一次(大列表逐条 new RegExp 是键入卡顿主源之一)
344
+ const nameHit = nameMatcherOf(filter.name)
328
345
  return entries.filter((entry) => {
329
- if (!changeNameMatch(entry.path, filter.name)) return false
346
+ if (!nameHit(entry.path)) return false
330
347
  if (entry.status === 'ignored') return showIgnored
331
348
  if (entry.status === 'unversioned') return showUnversioned
332
349
  return isSvnChange(entry)
@@ -443,6 +460,53 @@ export interface SvnRenamePair {
443
460
  unversionedPath: string
444
461
  }
445
462
 
463
+ /** 改名配对 size 批量查询单次上限(host `svn.fileSizes` 同值护栏;client 预截断对齐,防请求白打)。 */
464
+ export const FILE_SIZES_CAP = 200
465
+
466
+ /** 批量路径动作单次上限(revert/add/changelist 共用;host mutate 权威护栏,client 预分块对齐)。 */
467
+ export const BATCH_PATHS_CAP = 64
468
+
469
+ /** 分区名(changelist 名)长度上限(过长名可读性差且易混入选项位)。 */
470
+ export const SVN_CHANGELIST_NAME_MAX = 64
471
+
472
+ // --region P5 分区管理(changelist)
473
+
474
+ /**
475
+ * 分区名(changelist 名)合法性校验:返回错误文案,合法返回 null。
476
+ * 规则:去首尾空白后非空、不以 `-` 开头(防被 svn 当成选项解析)、长度 ≤ 上限。
477
+ * client 预检 + host 权威校验共用,避免两处漂移。
478
+ * @author ddj 2026年09月23号
479
+ * @param name 用户输入的分区名
480
+ * @returns 错误文案;合法为 null
481
+ */
482
+ export function svnChangelistNameErrorOf(name: string): string | null {
483
+ const trimmed = String(name ?? '').trim()
484
+ if (!trimmed) return '分区名不能为空'
485
+ if (trimmed.startsWith('-')) return '分区名不能以 - 开头'
486
+ if (trimmed.length > SVN_CHANGELIST_NAME_MAX) return '分区名最长 ' + SVN_CHANGELIST_NAME_MAX + ' 字符'
487
+ return null
488
+ }
489
+
490
+ /**
491
+ * 路径清单按单次上限分块(host 批量路径上限护栏的客户端对齐)。
492
+ * @author ddj 2026年09月23号
493
+ * @param paths 路径列表
494
+ * @param cap 单块上限(≤0 或非有限值视为不分块,整包一块)
495
+ * @returns 分块数组(保持原顺序;空入参返回 [])
496
+ */
497
+ export function svnChunksOf(paths: readonly string[], cap: number): string[][] {
498
+ const size = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : 0
499
+ if (!paths.length) return []
500
+ if (!size || paths.length <= size) return [[...paths]]
501
+ const chunks: string[][] = []
502
+ for (let start = 0; start < paths.length; start += size) {
503
+ chunks.push(paths.slice(start, start + size))
504
+ }
505
+ return chunks
506
+ }
507
+
508
+ // --endregion
509
+
446
510
  /** 取路径 basename(`/`/`\` 通用;纯函数)。 */
447
511
  function baseNameOf(path: string): string {
448
512
  return String(path ?? '').split(/[\\/]/).pop() ?? ''
@@ -522,3 +586,225 @@ export interface SvnConflictArtifact {
522
586
  }
523
587
 
524
588
  // --endregion
589
+
590
+ // --region AI 智能整理(方案类型/归一化,11-ai-changelist-triage)
591
+
592
+ /** AI 整理方案单条建议(还原/忽略)。 */
593
+ export interface SvnAiPlanItem {
594
+ /** 工作区相对路径。 */
595
+ path: string
596
+ /** AI 判定理由(预览展示用;可缺省)。 */
597
+ reason?: string
598
+ }
599
+
600
+ /** AI 整理方案分组建议(目标 changelist 组)。 */
601
+ export interface SvnAiPlanGroup {
602
+ /** 目标分区名(changelist 名)。 */
603
+ name: string
604
+ /** 组内路径(工作区相对)。 */
605
+ paths: string[]
606
+ /** 分组理由(预览展示用;可缺省)。 */
607
+ reason?: string
608
+ }
609
+
610
+ /** AI 整理方案(三段互斥:分组/还原/忽略;normalizeAiPlan 归一后一路径至多一段)。 */
611
+ export interface SvnAiPlan {
612
+ /** 建议分组(changelist)。 */
613
+ groups: SvnAiPlanGroup[]
614
+ /** 建议还原(revert)。 */
615
+ reverts: SvnAiPlanItem[]
616
+ /** 建议忽略(写 svn:ignore 属性)。 */
617
+ ignores: SvnAiPlanItem[]
618
+ }
619
+
620
+ /** svn:ignore 写入项(目标目录 + 该目录下新增忽略名列表)。 */
621
+ export interface SvnIgnoreItem {
622
+ /** 目录(工作区相对;'' = 工作副本根)。 */
623
+ dir: string
624
+ /** 该目录下要忽略的文件/目录名(basename)。 */
625
+ names: string[]
626
+ }
627
+
628
+ /** AI 方案分组数上限(超出丢弃,防止过度碎片化)。 */
629
+ export const AI_PLAN_GROUPS_CAP = 20
630
+
631
+ /** AI 方案分析条目数上限(超出仅按路径分析并标记截断)。 */
632
+ export const AI_PLAN_PATHS_CAP = 1500
633
+
634
+ /** AI 方案 diff 上下文字节上限(超出降级 paths-only)。 */
635
+ export const AI_PLAN_DIFF_CAP = 2 * 1024 * 1024
636
+
637
+ /** AI 方案 LLM 调用超时(毫秒;推理模型 + 高思考档位 + diff 上下文实测 120s 不够(huoshan/glm-5.3-flash),放宽到 5 分钟)。 */
638
+ export const AI_PLAN_TIMEOUT_MS = 300 * 1000
639
+
640
+ /** 松散数组取值(非数组按空处理;AI 输出形状容错)。 */
641
+ function listOf(raw: unknown): unknown[] {
642
+ return Array.isArray(raw) ? raw : []
643
+ }
644
+
645
+ /**
646
+ * AI 方案条目解析(宽松:接受 `{path, reason}` 或裸路径字符串;反斜杠归一为 `/`)。
647
+ * @author ddj 2026年09月23号
648
+ * @param raw 原始元素
649
+ * @returns 解析结果(不可解析返回 null——非路径垃圾不作数)
650
+ */
651
+ export function planItemOf(raw: unknown): SvnAiPlanItem | null {
652
+ const obj = typeof raw === 'string' ? { path: raw } : (raw && typeof raw === 'object' ? raw as { path?: unknown; reason?: unknown } : null)
653
+ if (!obj) return null
654
+ const path = typeof obj.path === 'string' ? obj.path.trim().replace(/\\/g, '/') : ''
655
+ if (!path) return null
656
+ const reason = typeof obj.reason === 'string' ? obj.reason.trim().slice(0, 120) : ''
657
+ return reason ? { path, reason } : { path }
658
+ }
659
+
660
+ /**
661
+ * AI 方案分组解析(宽松:name + paths 数组;路径逐个规范化)。
662
+ * @author ddj 2026年09月23号
663
+ * @param raw 原始元素
664
+ * @returns 解析结果(无合法名返回 null)
665
+ */
666
+ export function planGroupOf(raw: unknown): { name: string; paths: string[]; reason?: string } | null {
667
+ if (!raw || typeof raw !== 'object') return null
668
+ const obj = raw as { name?: unknown; paths?: unknown; reason?: unknown }
669
+ const name = typeof obj.name === 'string' ? obj.name.trim() : ''
670
+ if (!name) return null
671
+ const paths: string[] = []
672
+ for (const p of listOf(obj.paths)) {
673
+ const path = typeof p === 'string' ? p.trim().replace(/\\/g, '/') : ''
674
+ if (path) paths.push(path)
675
+ }
676
+ const reason = typeof obj.reason === 'string' ? obj.reason.trim().slice(0, 120) : ''
677
+ return reason ? { name, paths, reason } : { name, paths }
678
+ }
679
+
680
+ /**
681
+ * AI 方案归一化(纯函数):以真实变更清单为白名单裁决三段建议。
682
+ * - 幻觉路径(不在清单中)/状态不符(revert 须 versioned、ignore 须 unversioned)→ 丢弃并计数;
683
+ * - 互斥优先级 revert > ignore > group(同一路径至多归一段,还原后条目消失故优先);
684
+ * - 组名过 svnChangelistNameErrorOf、组数上限 AI_PLAN_GROUPS_CAP,空组剔除。
685
+ * @author ddj 2026年09月23号
686
+ * @param raw AI 输出解析结果(形状不定,内部容错)
687
+ * @param entries 真实变更清单(白名单来源)
688
+ * @returns 归一化方案与被丢弃的候选路径数
689
+ */
690
+ export function normalizeAiPlan(raw: unknown, entries: readonly SvnChangeEntry[]): { plan: SvnAiPlan; dropped: number } {
691
+ const plan: SvnAiPlan = { groups: [], reverts: [], ignores: [] }
692
+ if (!raw || typeof raw !== 'object') return { plan, dropped: 0 }
693
+ const byPath = new Map<string, SvnChangeEntry>()
694
+ for (const entry of entries) if (entry?.path) byPath.set(entry.path, entry)
695
+ const claimed = new Set<string>()
696
+ let dropped = 0
697
+ /** 单条裁决:真实存在 + 状态符合 + 未被更高优先级段认领。 */
698
+ const accept = (path: string, wantVersioned: boolean): boolean => {
699
+ const entry = byPath.get(path)
700
+ if (!entry || !isSvnChange(entry) || entry.versioned !== wantVersioned || claimed.has(path)) { dropped++; return false }
701
+ claimed.add(path)
702
+ return true
703
+ }
704
+ const src = raw as { groups?: unknown; reverts?: unknown; ignores?: unknown }
705
+ // 互斥优先级:revert > ignore > group
706
+ for (const el of listOf(src.reverts)) {
707
+ const item = planItemOf(el)
708
+ if (item && accept(item.path, true)) plan.reverts.push(item)
709
+ }
710
+ for (const el of listOf(src.ignores)) {
711
+ const item = planItemOf(el)
712
+ if (item && accept(item.path, false)) plan.ignores.push(item)
713
+ }
714
+ for (const el of listOf(src.groups)) {
715
+ const group = planGroupOf(el)
716
+ if (!group) {
717
+ // 形状不整但含路径候选(如空名组):候选路径同样计入 dropped,保持丢弃口径一致
718
+ const obj = el && typeof el === 'object' ? el as { paths?: unknown } : null
719
+ for (const p of listOf(obj?.paths)) {
720
+ if (typeof p === 'string' && p.trim()) dropped++
721
+ }
722
+ continue
723
+ }
724
+ const nameError = svnChangelistNameErrorOf(group.name) !== null
725
+ const over = plan.groups.length >= AI_PLAN_GROUPS_CAP
726
+ const valid: string[] = []
727
+ for (const path of group.paths) {
728
+ if (nameError || over) { dropped++; continue }
729
+ if (accept(path, true)) valid.push(path)
730
+ }
731
+ if (valid.length && !nameError && !over) {
732
+ plan.groups.push(group.reason ? { name: group.name, paths: valid, reason: group.reason } : { name: group.name, paths: valid })
733
+ }
734
+ }
735
+ return { plan, dropped }
736
+ }
737
+
738
+ /**
739
+ * 忽略路径清单 → 目录聚合写入项(纯函数):路径拆 dir + basename,同目录合并、同名去重。
740
+ * 供 `svn.ignore` 的执行载荷构造(一个目录一次 propset,进度粒度按目录)。
741
+ * @author ddj 2026年09月23号
742
+ * @param paths 未版本化路径清单(工作区相对)
743
+ * @returns 目录聚合写入项(保持目录首次出现顺序;空入参返回 [])
744
+ */
745
+ export function ignoreItemsOf(paths: readonly string[]): SvnIgnoreItem[] {
746
+ const byDir = new Map<string, string[]>()
747
+ for (const raw of paths) {
748
+ const norm = String(raw ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
749
+ if (!norm) continue
750
+ const cut = norm.lastIndexOf('/')
751
+ const dir = cut <= 0 ? '' : norm.slice(0, cut)
752
+ const name = cut <= 0 ? norm : norm.slice(cut + 1)
753
+ if (!name) continue
754
+ const bucket = byDir.get(dir)
755
+ if (bucket) {
756
+ if (!bucket.includes(name)) bucket.push(name)
757
+ } else {
758
+ byDir.set(dir, [name])
759
+ }
760
+ }
761
+ return [...byDir.entries()].map(([dir, names]) => ({ dir, names }))
762
+ }
763
+
764
+ /** 深度分析投递口地址(host RPC 本地免鉴权;agent 用 shell 直接 POST)。 */
765
+ const AGENT_SUBMIT_URL = 'http://127.0.0.1:3080/edrv/rpc'
766
+
767
+ /**
768
+ * 会话 agent 深度分析任务文本(混合通道 02-deep-session-prompt 修正版)。
769
+ * 在**独立新会话**草稿箱填入后由用户发送;agent 自跑只读 `svn status` 取清单
770
+ * (prompt 定长瘦身,不随变更数膨胀),借 codegraph/读文件/sub agent 深度分析,
771
+ * 产出方案经免鉴权 RPC `svn.aiPlanSubmit` 投递回面板收件箱——确认与执行仍全部
772
+ * 走面板(agent 禁写、只读放行)。
773
+ * @author ddj 2026年09月23号
774
+ * @param wcRoot 工作副本根(取数指引内嵌)
775
+ * @param sessionId 会话 id(投递命令内嵌;host 按它解析工作副本)
776
+ * @returns 任务文本(定长 ~1KB:取数指引/分析要求/投递命令/硬约束)
777
+ */
778
+ export function buildAgentPrompt(wcRoot: string, sessionId: string): string {
779
+ return [
780
+ '请对 SVN 工作副本「' + wcRoot + '」的本地变更做**深度分析**,产出整理方案(分组/还原/忽略三段)。',
781
+ '',
782
+ '取数(只读命令允许):',
783
+ '- 先执行 `svn status`(只读)获取完整变更清单;',
784
+ '- 判断改动性质时用 `svn diff`(只读)查看具体改动。',
785
+ '',
786
+ '分析要求(按需使用你的工具):',
787
+ '- 借助 codegraph 查询调用链/模块归属,判断哪些文件属于同一功能主题(分组依据);',
788
+ '- 按需读取文件内容,判断改动性质(误改/行尾空白噪音/试验性改动 → 建议还原);',
789
+ '- 未版本化条目判断是否为生成物/临时文件/日志/缓存(建议忽略);',
790
+ '- 需要并行探索时可用 sub agent。',
791
+ '',
792
+ '方案 JSON 形状(reason 为简短中文一句话):',
793
+ '{ "groups": [{ "name": "英文短横线名", "paths": ["路径1"], "reason": "理由" }],',
794
+ ' "reverts": [{ "path": "路径", "reason": "理由" }],',
795
+ ' "ignores": [{ "path": "路径", "reason": "理由" }] }',
796
+ '',
797
+ '硬约束:',
798
+ '1. 禁止任何**写**操作(revert/commit/changelist/propset/update/merge 等一律禁止);只读命令(status/diff/log/cat/info)允许;',
799
+ '2. 每个路径至多归一类(groups/reverts/ignores 互斥),拿不准的不填;',
800
+ '3. 组名用简短英文、不以 - 开头;',
801
+ '4. 只使用 `svn status` 实际返回的路径,绝不虚构或改写路径。',
802
+ '',
803
+ '分析完成后,用 shell 执行下面的命令把方案投递回面板(把 <PLAN_JSON> 替换为方案 JSON):',
804
+ 'Invoke-RestMethod -Uri ' + AGENT_SUBMIT_URL + ' -Method Post -ContentType \'application/json\' -Body (@{ method = \'svn.aiPlanSubmit\'; args = @{ sessionId = \'' + sessionId + '\'; plan = <PLAN_JSON> } } | ConvertTo-Json -Depth 8)',
805
+ '',
806
+ '投递成功会返回 { ok, accepted, dropped };随后我会在面板弹窗中确认执行,你无需再做任何操作。',
807
+ ].join('\n')
808
+ }
809
+
810
+ // --endregion