dsh-vscode-mode 0.1.47 → 0.1.49
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/README.md +10 -1
- package/lib/client.js +729 -205
- package/lib/client.js.map +1 -1
- package/lib/index.js +562 -3
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +32 -19
- package/src/client/compat.ts +1 -1
- package/src/client/index.ts +9 -2
- package/src/client/paths.ts +2 -0
- package/src/client/sidebar/SidebarView.ts +82 -16
- package/src/client/sidebar/menuItems.ts +23 -2
- package/src/client/sidebar/panels/FileExplorer.ts +2 -4
- package/src/client/sidebar/panels/RulesPanel.ts +284 -0
- package/src/client/sidebar/panels/index.ts +18 -2
- package/src/client/sidebar/types.ts +5 -0
- package/src/client/sidebarMin.ts +48 -0
- package/src/client/styles/editor.css +43 -0
- package/src/client/ui/EditorView.ts +25 -13
- package/src/client/ui/McpSettings.ts +37 -2
- package/src/client/ui/SideEditorTab.ts +1 -0
- package/src/client/ui/menuPosition.ts +2 -28
- package/src/fileOpenSettings.ts +2 -0
- package/src/index.ts +5 -1
- package/src/rpc.ts +37 -0
- package/src/rules.ts +528 -0
- package/src/shared/rpc.ts +11 -0
- package/src/shared/rules.ts +60 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 右键菜单 viewport 定位纯函数。
|
|
3
|
-
*
|
|
4
|
-
* 作者 ddj 2026-08-28
|
|
3
|
+
* 将视口边界 clamp 从 React 渲染层拆出,便于复用和单测。
|
|
4
|
+
* 作者 ddj 2026-08-28 / 2026-09-03
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export interface MenuPosition {
|
|
@@ -9,13 +9,6 @@ export interface MenuPosition {
|
|
|
9
9
|
top: number
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface MenuAnchorRect {
|
|
13
|
-
top: number
|
|
14
|
-
right: number
|
|
15
|
-
width: number
|
|
16
|
-
height: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
/**
|
|
20
13
|
* 将菜单坐标限制在 viewport 内。
|
|
21
14
|
* @author ddj 2026年08月28号
|
|
@@ -42,22 +35,3 @@ export function clampMenuPosition(
|
|
|
42
35
|
top: Math.max(4, Math.min(y, maxTop)),
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 将文件树行右键菜单锚定到目标行右侧;无法测量时回退到鼠标坐标。
|
|
48
|
-
* @author ddj 2026年08月28号
|
|
49
|
-
* @param rect 目标行的布局矩形
|
|
50
|
-
* @param fallbackX 鼠标 viewport x 坐标
|
|
51
|
-
* @param fallbackY 鼠标 viewport y 坐标
|
|
52
|
-
* @returns 菜单初始位置
|
|
53
|
-
*/
|
|
54
|
-
export function rowMenuPosition(
|
|
55
|
-
rect: MenuAnchorRect | null | undefined,
|
|
56
|
-
fallbackX: number,
|
|
57
|
-
fallbackY: number,
|
|
58
|
-
): MenuPosition {
|
|
59
|
-
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
60
|
-
return { left: fallbackX, top: fallbackY }
|
|
61
|
-
}
|
|
62
|
-
return { left: rect.right + 4, top: rect.top }
|
|
63
|
-
}
|
package/src/fileOpenSettings.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface SettingsDeps {
|
|
|
28
28
|
object: (shape: Record<string, unknown>) => { default: (value: Record<string, unknown>) => unknown }
|
|
29
29
|
string: () => { default: (value: string) => unknown }
|
|
30
30
|
boolean: () => { default: (value: boolean) => unknown }
|
|
31
|
+
number: () => { default: (value: number) => unknown }
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -208,6 +209,7 @@ export async function installOpenSettingsSection(
|
|
|
208
209
|
const schema = deps.z.object({
|
|
209
210
|
fileOpenTool: deps.z.string().default(FILE_OPEN_DEFAULT),
|
|
210
211
|
keybindings: deps.z.object(keybindingsShape(deps.z)).default({ ...KEYBINDING_DEFAULTS }),
|
|
212
|
+
sidebarMinWidth: deps.z.number().default(300),
|
|
211
213
|
})
|
|
212
214
|
const strategy = await runSettingsInstall(ctx, ns, schema, entry, {
|
|
213
215
|
setSource: (source) => hooks.setSource(source as () => FileOpenSettings),
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createLspManager } from './lsp/manager.js'
|
|
|
22
22
|
import { createLspRpc } from './lsp/rpc.js'
|
|
23
23
|
import { installLspSettingsSection } from './lsp/settings.js'
|
|
24
24
|
import { disposeAllServers } from './lsp/transport.js'
|
|
25
|
+
import { installRulesSection } from './rules.js'
|
|
25
26
|
import type { RpcHandlerMap } from './shared/rpc.js'
|
|
26
27
|
import type { Registry } from './registry.js'
|
|
27
28
|
import type { Ctx } from './store.js'
|
|
@@ -45,6 +46,9 @@ export function apply(ctx: Ctx, config?: unknown): void {
|
|
|
45
46
|
const warnings: string[] = []
|
|
46
47
|
setupOpenSettings(ctx, config, () => {})
|
|
47
48
|
void installLspSettingsSection(ctx, {})
|
|
49
|
+
/** 规则注入 section(~/.dsh/rules 与 <工作区>/.dsh/rules;旧版 DSH 无 systemPrompt 时静默降级)。 */
|
|
50
|
+
const rulesInstalled = installRulesSection(ctx)
|
|
51
|
+
if (!rulesInstalled) ctx.logger?.warn?.('[' + name + '] 未检测到 systemPrompt 服务,规则仅可管理不注入')
|
|
48
52
|
/** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
|
|
49
53
|
const lspRpc = createLspRpc({ ctx, pluginConfig: config, manager: lspManager })
|
|
50
54
|
const lspHandlers: Partial<RpcHandlerMap> = lspRpc.handlers
|
|
@@ -77,7 +81,7 @@ export function apply(ctx: Ctx, config?: unknown): void {
|
|
|
77
81
|
disposeAllServers()
|
|
78
82
|
})
|
|
79
83
|
|
|
80
|
-
ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP
|
|
84
|
+
ctx.logger?.info?.('[' + name + '] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入,规则注入' + (rulesInstalled ? '已接入' : '未接入') + ')')
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
package/src/rpc.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { newSearcher } from './search/orchestrator.js'
|
|
|
28
28
|
import type { ContentSearcher } from './search/content.js'
|
|
29
29
|
import { newContentSearcher } from './search/content.js'
|
|
30
30
|
import { restoreFile, revertCall, revertHunk } from './revert.js'
|
|
31
|
+
import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
|
|
31
32
|
import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
32
33
|
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
33
34
|
import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS } from './fileOpenSettings.js'
|
|
@@ -661,6 +662,42 @@ export function buildHandlers(
|
|
|
661
662
|
return { ok: false, error: '撤销压缩配置失败:' + String(error) }
|
|
662
663
|
}
|
|
663
664
|
},
|
|
665
|
+
'rules.list': async () => {
|
|
666
|
+
try {
|
|
667
|
+
return { ok: true, ...(await rulesList(ctx)) }
|
|
668
|
+
} catch (error) {
|
|
669
|
+
return { ok: false, error: '读取规则失败:' + String(error) }
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
'rules.read': async (args) => {
|
|
673
|
+
try {
|
|
674
|
+
return { ok: true, content: await rulesRead(ctx, args) }
|
|
675
|
+
} catch (error) {
|
|
676
|
+
return { ok: false, error: '读取规则失败:' + String(error) }
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
'rules.save': async (args) => {
|
|
680
|
+
try {
|
|
681
|
+
return { ok: true, rule: await rulesSave(ctx, args) }
|
|
682
|
+
} catch (error) {
|
|
683
|
+
return { ok: false, error: '保存规则失败:' + String(error) }
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
'rules.remove': async (args) => {
|
|
687
|
+
try {
|
|
688
|
+
await rulesRemove(ctx, args)
|
|
689
|
+
return { ok: true }
|
|
690
|
+
} catch (error) {
|
|
691
|
+
return { ok: false, error: '删除规则失败:' + String(error) }
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
'rules.toggle': async (args) => {
|
|
695
|
+
try {
|
|
696
|
+
return { ok: true, rule: await rulesToggle(ctx, args, args.enabled === true) }
|
|
697
|
+
} catch (error) {
|
|
698
|
+
return { ok: false, error: '切换规则失败:' + String(error) }
|
|
699
|
+
}
|
|
700
|
+
},
|
|
664
701
|
}
|
|
665
702
|
}
|
|
666
703
|
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 规则管理(Codebuddy/Cursor 式 .mdc 规则)。
|
|
3
|
+
* - 存储:用户规则 ~/.dsh/rules/*.mdc;项目规则 <工作区>/.dsh/rules/*.mdc(随仓库共享)。
|
|
4
|
+
* - 格式:frontmatter(description / alwaysApply / globs / enabled)+ markdown 正文;
|
|
5
|
+
* 类型映射 总是=always / 自动=auto(globs) / 手动=manual(仅索引);enabled 为本插件扩展字段,缺省 true。
|
|
6
|
+
* - 生效:host 注册 systemPrompt.section(order 400),每次装配同步读取(mtime 缓存),
|
|
7
|
+
* 项目规则按 AssembleContext.agent.id → 会话 cwd 注入;旧版 DSH 缺服务时优雅降级。
|
|
8
|
+
* --region 划分:常量 / frontmatter 解析(纯)/ 文件名与开关(纯)/ 注入渲染(纯)/ IO / systemPrompt 装配
|
|
9
|
+
* 作者 ddj 2026年09月03号
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
12
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { Buffer } from 'node:buffer'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { dshHome } from './paths.js'
|
|
16
|
+
import type { RuleInfo, RuleProject, RuleRefInput, RuleScope, RuleSaveInput } from './shared/rules.js'
|
|
17
|
+
import type { Ctx } from './store.js'
|
|
18
|
+
|
|
19
|
+
// --region 常量与目录定位
|
|
20
|
+
/** 规则文件名白名单:字母数字开头,仅字母数字点横下划线,.mdc 后缀(天然拒绝路径分隔符)。 */
|
|
21
|
+
export const RULE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.mdc$/
|
|
22
|
+
/** Windows 保留设备名(防意外创建系统设备文件)。 */
|
|
23
|
+
const RESERVED_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
|
24
|
+
/** 单目录最多加载的规则文件数(防失控目录拖垮装配)。 */
|
|
25
|
+
const RULE_DIR_CAP = 200
|
|
26
|
+
/** 注入时单条规则正文字节上限(超出截断)。 */
|
|
27
|
+
const RULE_SINGLE_CAP = 16 * 1024
|
|
28
|
+
/** 注入时单个作用域(用户/项目)总字节预算。 */
|
|
29
|
+
const RULE_SCOPE_CAP = 64 * 1024
|
|
30
|
+
/** systemPrompt section 名(同层唯一)。 */
|
|
31
|
+
const SECTION_NAME = 'dsh-vscode-mode:rules'
|
|
32
|
+
/** section 顺序:persona(0) 与 PLAN_POLICY(500) 之间,规则先于计划策略被读到。 */
|
|
33
|
+
const SECTION_ORDER = 400
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 用户规则目录(~/.dsh/rules)。
|
|
37
|
+
* @author ddj 2026年09月03号
|
|
38
|
+
* @param home DSH home(缺省自动解析)
|
|
39
|
+
* @returns 绝对路径
|
|
40
|
+
*/
|
|
41
|
+
export function userRulesDir(home = dshHome()): string {
|
|
42
|
+
return join(home, 'rules')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 项目规则目录(<工作区>/.dsh/rules)。
|
|
47
|
+
* @author ddj 2026年09月03号
|
|
48
|
+
* @param workspacePath 工作区绝对路径
|
|
49
|
+
* @returns 绝对路径
|
|
50
|
+
*/
|
|
51
|
+
export function projectRulesDir(workspacePath: string): string {
|
|
52
|
+
return join(workspacePath, '.dsh', 'rules')
|
|
53
|
+
}
|
|
54
|
+
// --endregion
|
|
55
|
+
|
|
56
|
+
// --region frontmatter 解析(纯函数)
|
|
57
|
+
/** parseRuleMdc 的产出:frontmatter 字段 + 正文 + 可选解析错误。 */
|
|
58
|
+
export interface ParsedRule {
|
|
59
|
+
description: string
|
|
60
|
+
alwaysApply: boolean
|
|
61
|
+
globs: string[]
|
|
62
|
+
enabled: boolean
|
|
63
|
+
body: string
|
|
64
|
+
error?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 去除值两侧成对引号。
|
|
69
|
+
* @author ddj 2026年09月03号
|
|
70
|
+
* @param raw 原始值
|
|
71
|
+
* @returns 去引号后的值
|
|
72
|
+
*/
|
|
73
|
+
function stripQuotes(raw: string): string {
|
|
74
|
+
const value = raw.trim()
|
|
75
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
76
|
+
return value.slice(1, -1)
|
|
77
|
+
}
|
|
78
|
+
return value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解析 globs 值:内联逗号分隔串,或空值后接 YAML 短横列表。
|
|
83
|
+
* @author ddj 2026年09月03号
|
|
84
|
+
* @param value 内联值(可为空)
|
|
85
|
+
* @param listLines 后续短横列表行内容(去掉 `- ` 前缀)
|
|
86
|
+
* @returns glob 数组
|
|
87
|
+
*/
|
|
88
|
+
function parseGlobs(value: string, listLines: string[]): string[] {
|
|
89
|
+
const inline = stripQuotes(value)
|
|
90
|
+
const items = inline ? inline.split(',') : listLines
|
|
91
|
+
return items.map((item) => stripQuotes(item)).filter(Boolean)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 容错解析一条 .mdc 规则:首行 `---` 且 100 行内有闭合 `---` 才视为 frontmatter,
|
|
96
|
+
* 否则整个文本当正文(类型 manual、enabled=true)。解析永不抛错。
|
|
97
|
+
* @author ddj 2026年09月03号
|
|
98
|
+
* @param text 文件全文
|
|
99
|
+
* @returns 解析结果
|
|
100
|
+
*/
|
|
101
|
+
export function parseRuleMdc(text: string): ParsedRule {
|
|
102
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
|
|
103
|
+
if (lines[0]?.trim() !== '---') return { description: '', alwaysApply: false, globs: [], enabled: true, body: text }
|
|
104
|
+
let close = -1
|
|
105
|
+
for (let i = 1; i < lines.length && i <= 101; i++) {
|
|
106
|
+
if (lines[i].trim() === '---') { close = i; break }
|
|
107
|
+
}
|
|
108
|
+
if (close < 0) return { description: '', alwaysApply: false, globs: [], enabled: true, body: text }
|
|
109
|
+
const result: ParsedRule = { description: '', alwaysApply: false, globs: [], enabled: true, body: lines.slice(close + 1).join('\n') }
|
|
110
|
+
for (let i = 1; i < close; i++) {
|
|
111
|
+
const match = /^([A-Za-z_-]+)[ \t]*:[ \t]?(.*)$/.exec(lines[i])
|
|
112
|
+
if (!match) continue
|
|
113
|
+
const [, key, raw] = match
|
|
114
|
+
if (key === 'description') result.description = stripQuotes(raw)
|
|
115
|
+
else if (key === 'alwaysApply') result.alwaysApply = stripQuotes(raw).toLowerCase() === 'true'
|
|
116
|
+
else if (key === 'enabled') result.enabled = stripQuotes(raw).toLowerCase() !== 'false'
|
|
117
|
+
else if (key === 'globs') {
|
|
118
|
+
const listLines: string[] = []
|
|
119
|
+
for (let j = i + 1; j < close; j++) {
|
|
120
|
+
const item = /^[ \t]*-[ \t]*(.+)$/.exec(lines[j])
|
|
121
|
+
if (!item) break
|
|
122
|
+
listLines.push(item[1])
|
|
123
|
+
}
|
|
124
|
+
result.globs = parseGlobs(raw, listLines)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 由 frontmatter 推导规则类型:总是 / 自动(有 globs)/ 手动。
|
|
132
|
+
* @author ddj 2026年09月03号
|
|
133
|
+
* @param parsed 解析结果
|
|
134
|
+
* @returns 规则类型
|
|
135
|
+
*/
|
|
136
|
+
export function ruleTypeOf(parsed: ParsedRule): 'always' | 'auto' | 'manual' {
|
|
137
|
+
if (parsed.alwaysApply) return 'always'
|
|
138
|
+
if (parsed.globs.length) return 'auto'
|
|
139
|
+
return 'manual'
|
|
140
|
+
}
|
|
141
|
+
// --endregion
|
|
142
|
+
|
|
143
|
+
// --region 文件名与开关改写(纯函数)
|
|
144
|
+
/**
|
|
145
|
+
* 校验规则文件名(白名单 + Windows 保留名拒绝)。
|
|
146
|
+
* @author ddj 2026年09月03号
|
|
147
|
+
* @param name 文件名(含后缀)
|
|
148
|
+
* @returns 错误文案;null=合法
|
|
149
|
+
*/
|
|
150
|
+
export function validateRuleFile(name: string): string | null {
|
|
151
|
+
if (!RULE_FILE_RE.test(name)) return '文件名不合法:仅允许字母数字开头,含字母/数字/点/横线/下划线,.mdc 后缀'
|
|
152
|
+
if (RESERVED_NAME_RE.test(name)) return '文件名是 Windows 保留设备名,不允许'
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 只改写 frontmatter 的 enabled 行(保留 BOM、CRLF 与其余内容不动);无规则 frontmatter 返回 null。
|
|
158
|
+
* @author ddj 2026年09月03号
|
|
159
|
+
* @param text 文件全文
|
|
160
|
+
* @param enabled 目标开关状态
|
|
161
|
+
* @returns 改写后的全文;null=无法安全改写
|
|
162
|
+
*/
|
|
163
|
+
export function toggleEnabledLine(text: string, enabled: boolean): string | null {
|
|
164
|
+
const hasBom = text.startsWith('\uFEFF')
|
|
165
|
+
const rest = hasBom ? text.slice(1) : text
|
|
166
|
+
const eol = rest.includes('\r\n') ? '\r\n' : '\n'
|
|
167
|
+
const lines = rest.split(/\r?\n/)
|
|
168
|
+
if (lines[0]?.trim() !== '---') return null
|
|
169
|
+
let close = -1
|
|
170
|
+
for (let i = 1; i < lines.length && i <= 101; i++) {
|
|
171
|
+
if (lines[i].trim() === '---') { close = i; break }
|
|
172
|
+
}
|
|
173
|
+
if (close < 0) return null
|
|
174
|
+
const line = 'enabled: ' + (enabled ? 'true' : 'false')
|
|
175
|
+
let replaced = false
|
|
176
|
+
for (let i = 1; i < close; i++) {
|
|
177
|
+
if (/^[ \t]*enabled[ \t]*:/.test(lines[i])) { lines[i] = line; replaced = true; break }
|
|
178
|
+
}
|
|
179
|
+
if (!replaced) lines.splice(1, 0, line)
|
|
180
|
+
return (hasBom ? '\uFEFF' : '') + lines.join(eol)
|
|
181
|
+
}
|
|
182
|
+
// --endregion
|
|
183
|
+
|
|
184
|
+
// --region 注入渲染(纯函数)
|
|
185
|
+
/** 一条已加载规则:元信息 + 正文(注入渲染输入)。 */
|
|
186
|
+
export interface LoadedRule {
|
|
187
|
+
info: RuleInfo
|
|
188
|
+
body: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 渲染单条规则块:标题 + 描述 + 截断后的正文;auto 类型追加 glob 条件说明。
|
|
193
|
+
* @author ddj 2026年09月03号
|
|
194
|
+
* @param rule 已加载规则
|
|
195
|
+
* @returns 文本块(含尾空行)
|
|
196
|
+
*/
|
|
197
|
+
function ruleBlock(rule: LoadedRule): string {
|
|
198
|
+
let body = rule.body
|
|
199
|
+
if (body.length > RULE_SINGLE_CAP) body = body.slice(0, RULE_SINGLE_CAP) + '\n…(规则正文超长已截断)'
|
|
200
|
+
const condition = rule.info.type === 'auto' && rule.info.globs.length
|
|
201
|
+
? '(当处理匹配 ' + rule.info.globs.join(', ') + ' 的文件时应用)'
|
|
202
|
+
: ''
|
|
203
|
+
const desc = rule.info.description ? '\n' + rule.info.description : ''
|
|
204
|
+
return '#### ' + rule.info.file + condition + desc + '\n' + body + '\n'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 渲染一个作用域(用户/项目)的注入段:总是全文 / 自动带条件 / 手动仅索引行。
|
|
209
|
+
* @author ddj 2026年09月03号
|
|
210
|
+
* @param title 段标题
|
|
211
|
+
* @param rules 已加载规则
|
|
212
|
+
* @returns 段文本;无启用规则时返回空串
|
|
213
|
+
*/
|
|
214
|
+
function renderScope(title: string, rules: LoadedRule[]): string {
|
|
215
|
+
const enabled = rules.filter((rule) => rule.info.enabled && !rule.info.error)
|
|
216
|
+
const always = enabled.filter((rule) => rule.info.type === 'always')
|
|
217
|
+
const auto = enabled.filter((rule) => rule.info.type === 'auto')
|
|
218
|
+
const manual = enabled.filter((rule) => rule.info.type === 'manual')
|
|
219
|
+
if (!always.length && !auto.length && !manual.length) return ''
|
|
220
|
+
const out: string[] = ['## ' + title, '']
|
|
221
|
+
let budget = RULE_SCOPE_CAP
|
|
222
|
+
let omitted = false
|
|
223
|
+
const push = (block: string): void => {
|
|
224
|
+
if (block.length > budget) { omitted = true; return }
|
|
225
|
+
budget -= block.length
|
|
226
|
+
out.push(block)
|
|
227
|
+
}
|
|
228
|
+
if (always.length) {
|
|
229
|
+
out.push('### 总是生效', '')
|
|
230
|
+
for (const rule of always) push(ruleBlock(rule))
|
|
231
|
+
}
|
|
232
|
+
if (auto.length) {
|
|
233
|
+
out.push('### 按文件匹配生效', '')
|
|
234
|
+
for (const rule of auto) push(ruleBlock(rule))
|
|
235
|
+
}
|
|
236
|
+
if (manual.length) {
|
|
237
|
+
out.push('### 可按需读取(未自动生效;需要时读取对应文件)', '')
|
|
238
|
+
for (const rule of manual) {
|
|
239
|
+
const desc = rule.info.description ? ' — ' + rule.info.description : ''
|
|
240
|
+
push('- ' + rule.info.file + desc + '(路径: ' + rule.info.absPath + ')\n')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (omitted) out.push('', '(部分规则因总长度预算被省略,请精简规则文件)')
|
|
244
|
+
return out.join('\n') + '\n'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 渲染规则注入 section 全文:用户规则段 + 项目规则段;两者皆空返回空串(空 section 无害)。
|
|
249
|
+
* @author ddj 2026年09月03号
|
|
250
|
+
* @param user 用户规则
|
|
251
|
+
* @param project 当前工作区项目规则
|
|
252
|
+
* @param workspacePath 项目段标注的工作区路径(缺省跳过项目段)
|
|
253
|
+
* @returns 注入文本
|
|
254
|
+
*/
|
|
255
|
+
export function renderRulesSection(user: LoadedRule[], project: LoadedRule[], workspacePath?: string): string {
|
|
256
|
+
const userText = renderScope('用户规则(用户配置,必须遵守)', user)
|
|
257
|
+
const projectText = workspacePath ? renderScope('项目规则(工作区 ' + workspacePath + ',必须遵守)', project) : ''
|
|
258
|
+
return [userText, projectText].filter(Boolean).join('\n')
|
|
259
|
+
}
|
|
260
|
+
// --endregion
|
|
261
|
+
|
|
262
|
+
// --region IO(列表 / 读 / 存 / 删 / 开关)
|
|
263
|
+
/** 单目录规则元信息列表(仅 *.mdc,最多 RULE_DIR_CAP 个;目录缺失返回空)。 */
|
|
264
|
+
async function listRulesDir(dir: string, scope: RuleScope): Promise<RuleInfo[]> {
|
|
265
|
+
const names = await readdir(dir).catch(() => [] as string[])
|
|
266
|
+
const mdcs = names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)
|
|
267
|
+
const out: RuleInfo[] = []
|
|
268
|
+
for (const file of mdcs) {
|
|
269
|
+
const absPath = join(dir, file)
|
|
270
|
+
const info = await stat(absPath).catch(() => null)
|
|
271
|
+
if (!info || !info.isFile()) continue
|
|
272
|
+
const parsed = parseRuleMdc(await readFile(absPath, 'utf8').catch(() => ''))
|
|
273
|
+
out.push(toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs))
|
|
274
|
+
}
|
|
275
|
+
return out
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 已注册 workspace 列表(项目 Tab 与写入校验共用)。 */
|
|
279
|
+
function workspaceList(ctx: Ctx): Array<{ path: string; title?: string }> {
|
|
280
|
+
const list = ctx.get('workspaceRegistry')?.list?.() ?? []
|
|
281
|
+
return (list as Array<{ path?: string; title?: string }>).filter(
|
|
282
|
+
(item): item is { path: string; title?: string } => Boolean(item) && typeof item.path === 'string' && item.path !== '',
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 用户显式 GUI 写操作的策略:danger-full-access(照抄 mcpProject.fullPolicy)。 */
|
|
287
|
+
function fullPolicy(ctx: Ctx): unknown {
|
|
288
|
+
const svc = ctx.get('sandboxPolicy')
|
|
289
|
+
if (!svc || typeof svc.resolve !== 'function') return undefined
|
|
290
|
+
return svc.resolve({ mode: 'danger-full-access' })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 校验项目作用域的 workspacePath 已注册为 DSH workspace(防 RPC 写任意目录)。 */
|
|
294
|
+
function requireWorkspace(ctx: Ctx, workspacePath: string | undefined): { path: string; title?: string } {
|
|
295
|
+
const workspace = workspaceList(ctx).find((item) => item.path === workspacePath)
|
|
296
|
+
if (!workspace) throw new Error('项目未注册为 DSH workspace,不能管理项目规则')
|
|
297
|
+
return workspace as { path: string; title?: string }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 按作用域解析规则目录(project 先过 requireWorkspace)。 */
|
|
301
|
+
function dirOf(ctx: Ctx, scope: RuleScope, workspacePath?: string): string {
|
|
302
|
+
if (scope === 'project') return projectRulesDir(requireWorkspace(ctx, workspacePath).path)
|
|
303
|
+
return userRulesDir()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 规则总列表:用户规则 + 各已注册工作区的项目规则。
|
|
308
|
+
* @author ddj 2026年09月03号
|
|
309
|
+
* @param ctx DSH 上下文
|
|
310
|
+
* @returns rules.list 载荷
|
|
311
|
+
*/
|
|
312
|
+
export async function rulesList(ctx: Ctx): Promise<{ user: RuleInfo[]; projects: RuleProject[] }> {
|
|
313
|
+
const user = await listRulesDir(userRulesDir(), 'user')
|
|
314
|
+
const projects: RuleProject[] = []
|
|
315
|
+
for (const workspace of workspaceList(ctx)) {
|
|
316
|
+
const dir = projectRulesDir(workspace.path)
|
|
317
|
+
const exists = existsSync(dir)
|
|
318
|
+
const rules = exists ? await listRulesDir(dir, 'project') : []
|
|
319
|
+
projects.push({ workspacePath: workspace.path, title: workspace.title ?? '', rules, missingDir: exists ? undefined : true })
|
|
320
|
+
}
|
|
321
|
+
return { user, projects }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 读取一条规则全文(编辑器回填用)。
|
|
326
|
+
* @author ddj 2026年09月03号
|
|
327
|
+
* @param ctx DSH 上下文
|
|
328
|
+
* @param ref 作用域 + 文件名(+ 项目工作区)
|
|
329
|
+
* @returns 文件全文
|
|
330
|
+
*/
|
|
331
|
+
export async function rulesRead(ctx: Ctx, ref: RuleRefInput): Promise<string> {
|
|
332
|
+
const dir = dirOf(ctx, ref.scope, ref.workspacePath)
|
|
333
|
+
return readFile(join(dir, ref.file), 'utf8')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 组装规则元信息视图(rulesSave / rulesToggle / 注入缓存共用)。
|
|
338
|
+
* @author ddj 2026年09月03号
|
|
339
|
+
* @param scope 作用域
|
|
340
|
+
* @param file 文件名
|
|
341
|
+
* @param absPath 绝对路径
|
|
342
|
+
* @param parsed 解析结果
|
|
343
|
+
* @param size 字节数
|
|
344
|
+
* @param mtime 修改时间毫秒
|
|
345
|
+
* @returns 规则元信息
|
|
346
|
+
*/
|
|
347
|
+
function toRuleInfo(scope: RuleScope, file: string, absPath: string, parsed: ParsedRule, size: number, mtime: number): RuleInfo {
|
|
348
|
+
return {
|
|
349
|
+
scope, file, absPath,
|
|
350
|
+
relHint: scope === 'project' ? '.dsh/rules/' : 'rules/',
|
|
351
|
+
description: parsed.description, type: ruleTypeOf(parsed), globs: parsed.globs, enabled: parsed.enabled,
|
|
352
|
+
size, mtime, error: parsed.error,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 保存(新建或覆盖)一条规则:project 作用域写盘走 ctx fs + danger-full-access(镜像 mcpProject)。
|
|
358
|
+
* @author ddj 2026年09月03号
|
|
359
|
+
* @param ctx DSH 上下文
|
|
360
|
+
* @param input 保存入参
|
|
361
|
+
* @returns 保存后的规则元信息
|
|
362
|
+
*/
|
|
363
|
+
export async function rulesSave(ctx: Ctx, input: RuleSaveInput): Promise<RuleInfo> {
|
|
364
|
+
const invalid = validateRuleFile(input.file)
|
|
365
|
+
if (invalid) throw new Error(invalid)
|
|
366
|
+
const dir = dirOf(ctx, input.scope, input.workspacePath)
|
|
367
|
+
await mkdir(dir, { recursive: true })
|
|
368
|
+
const absPath = join(dir, input.file)
|
|
369
|
+
const content = String(input.content ?? '')
|
|
370
|
+
if (input.scope === 'project') {
|
|
371
|
+
const fs = ctx.get('fs')
|
|
372
|
+
if (!fs) throw new Error('缺少 fs 服务')
|
|
373
|
+
const target = await fs.resolve('.dsh/rules/' + input.file, { cwd: requireWorkspace(ctx, input.workspacePath).path })
|
|
374
|
+
await fs.writeText(target, content, void 0, void 0, fullPolicy(ctx))
|
|
375
|
+
} else {
|
|
376
|
+
await writeFile(absPath, content, 'utf8')
|
|
377
|
+
}
|
|
378
|
+
const info = await stat(absPath).catch(() => null)
|
|
379
|
+
return toRuleInfo(input.scope, input.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 删除一条规则文件(project 作用域同样先过 workspace 注册校验)。
|
|
384
|
+
* @author ddj 2026年09月03号
|
|
385
|
+
* @param ref 作用域 + 文件名(+ 项目工作区)
|
|
386
|
+
*/
|
|
387
|
+
export async function rulesRemove(ctx: Ctx, ref: RuleRefInput): Promise<void> {
|
|
388
|
+
const dir = dirOf(ctx, ref.scope, ref.workspacePath)
|
|
389
|
+
await rm(join(dir, ref.file), { force: true })
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 切换规则启用开关:只改写 frontmatter enabled 行,其余内容零改动。
|
|
394
|
+
* @author ddj 2026年09月03号
|
|
395
|
+
* @param ref 作用域 + 文件名(+ 项目工作区)
|
|
396
|
+
* @param enabled 目标状态
|
|
397
|
+
* @returns 更新后的规则元信息
|
|
398
|
+
*/
|
|
399
|
+
export async function rulesToggle(ctx: Ctx, ref: RuleRefInput, enabled: boolean): Promise<RuleInfo> {
|
|
400
|
+
const dir = dirOf(ctx, ref.scope, ref.workspacePath)
|
|
401
|
+
const absPath = join(dir, ref.file)
|
|
402
|
+
const text = await readRuleText(ctx, ref, absPath)
|
|
403
|
+
if (text === null) throw new Error('规则文件不存在:' + ref.file)
|
|
404
|
+
const next = toggleEnabledLine(text, enabled)
|
|
405
|
+
if (next === null) throw new Error('规则缺少 frontmatter(首行需为 ---),无法记录开关状态')
|
|
406
|
+
if (next !== text) await rulesSaveContent(ctx, ref, absPath, next)
|
|
407
|
+
return await ruleInfoOf(ref, absPath, next)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 按作用域读规则全文:project 与写盘同通道走 ctx fs,user 走 node fs;缺失返回 null。 */
|
|
411
|
+
async function readRuleText(ctx: Ctx, ref: RuleRefInput, absPath: string): Promise<string | null> {
|
|
412
|
+
if (ref.scope !== 'project') return readFile(absPath, 'utf8').catch(() => null)
|
|
413
|
+
const fs = ctx.get('fs')
|
|
414
|
+
if (!fs) throw new Error('缺少 fs 服务')
|
|
415
|
+
const target = await fs.resolve('.dsh/rules/' + ref.file, { cwd: requireWorkspace(ctx, ref.workspacePath).path })
|
|
416
|
+
try {
|
|
417
|
+
return await fs.readText(target)
|
|
418
|
+
} catch {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** rulesToggle 的写盘通道:与 rulesSave 相同(project 走 ctx fs + fullPolicy,user 走 node fs)。 */
|
|
424
|
+
async function rulesSaveContent(ctx: Ctx, ref: RuleRefInput, absPath: string, content: string): Promise<void> {
|
|
425
|
+
if (ref.scope !== 'project') {
|
|
426
|
+
await writeFile(absPath, content, 'utf8')
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
const fs = ctx.get('fs')
|
|
430
|
+
if (!fs) throw new Error('缺少 fs 服务')
|
|
431
|
+
const target = await fs.resolve('.dsh/rules/' + ref.file, { cwd: requireWorkspace(ctx, ref.workspacePath).path })
|
|
432
|
+
await fs.writeText(target, content, void 0, void 0, fullPolicy(ctx))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** 重建单条规则元信息(toggle 后回传 UI;stat 失败时用内容长度 + 当前时间兜底)。 */
|
|
436
|
+
async function ruleInfoOf(ref: RuleRefInput, absPath: string, content: string): Promise<RuleInfo> {
|
|
437
|
+
const info = await stat(absPath).catch(() => null)
|
|
438
|
+
return toRuleInfo(ref.scope, ref.file, absPath, parseRuleMdc(content), info?.size ?? Buffer.byteLength(content), info?.mtimeMs ?? Date.now())
|
|
439
|
+
}
|
|
440
|
+
// --endregion
|
|
441
|
+
|
|
442
|
+
// --region systemPrompt 装配(注入生效通道)
|
|
443
|
+
/** 注入读取的 mtime 缓存项:mtimeMs+size 命中即复用解析结果,避免每次装配读盘解析。 */
|
|
444
|
+
interface InjCacheEntry {
|
|
445
|
+
mtimeMs: number
|
|
446
|
+
size: number
|
|
447
|
+
parsed: ParsedRule
|
|
448
|
+
}
|
|
449
|
+
const injCache = new Map<string, InjCacheEntry>()
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 同步读取一个规则目录(装配 provider 内专用):仅 *.mdc,mtime 缓存,异常静默为空。
|
|
453
|
+
* @author ddj 2026年09月03号
|
|
454
|
+
* @param dir 规则目录
|
|
455
|
+
* @param scope 作用域
|
|
456
|
+
* @param relHint 相对提示
|
|
457
|
+
* @returns 已加载规则列表
|
|
458
|
+
*/
|
|
459
|
+
function readRulesSync(dir: string, scope: RuleScope): LoadedRule[] {
|
|
460
|
+
if (!existsSync(dir)) return []
|
|
461
|
+
let names: string[]
|
|
462
|
+
try {
|
|
463
|
+
names = readdirSync(dir)
|
|
464
|
+
} catch {
|
|
465
|
+
return []
|
|
466
|
+
}
|
|
467
|
+
const out: LoadedRule[] = []
|
|
468
|
+
for (const file of names.filter((name) => name.endsWith('.mdc')).sort().slice(0, RULE_DIR_CAP)) {
|
|
469
|
+
const absPath = join(dir, file)
|
|
470
|
+
let info: { mtimeMs: number; size: number; isFile(): boolean }
|
|
471
|
+
try {
|
|
472
|
+
info = statSync(absPath)
|
|
473
|
+
} catch {
|
|
474
|
+
continue
|
|
475
|
+
}
|
|
476
|
+
if (!info.isFile()) continue
|
|
477
|
+
const cached = injCache.get(absPath)
|
|
478
|
+
let parsed: ParsedRule
|
|
479
|
+
if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
|
|
480
|
+
parsed = cached.parsed
|
|
481
|
+
} else {
|
|
482
|
+
try {
|
|
483
|
+
parsed = parseRuleMdc(readFileSync(absPath, 'utf8'))
|
|
484
|
+
} catch {
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
injCache.set(absPath, { mtimeMs: info.mtimeMs, size: info.size, parsed })
|
|
488
|
+
}
|
|
489
|
+
out.push({
|
|
490
|
+
info: toRuleInfo(scope, file, absPath, parsed, info.size, info.mtimeMs),
|
|
491
|
+
body: parsed.body,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
return out
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** 从装配上下文解析会话工作区 cwd(agent.id → sessions → header.cwd;缺链返回 null)。 */
|
|
498
|
+
function cwdFromAssemble(ctx: Ctx, asm: { agent?: { id?: unknown } } | undefined): string | null {
|
|
499
|
+
const id = asm?.agent?.id
|
|
500
|
+
if (typeof id !== 'string' || !id) return null
|
|
501
|
+
const session = ctx.get('sessions')?.get?.(id)
|
|
502
|
+
const cwd = session?.header?.cwd
|
|
503
|
+
return typeof cwd === 'string' && cwd ? cwd : null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 安装规则注入 section(可选探测 systemPrompt 服务;失败/缺失返回 false,不致命)。
|
|
508
|
+
* @author ddj 2026年09月03号
|
|
509
|
+
* @param ctx DSH host 上下文
|
|
510
|
+
* @returns 是否成功注册
|
|
511
|
+
*/
|
|
512
|
+
export function installRulesSection(ctx: Ctx): boolean {
|
|
513
|
+
const sp = ctx.get('systemPrompt')
|
|
514
|
+
if (!sp || typeof sp.section !== 'function') return false
|
|
515
|
+
const provider = (asm: { agent?: { id?: unknown } }): string => {
|
|
516
|
+
try {
|
|
517
|
+
const user = readRulesSync(userRulesDir(), 'user')
|
|
518
|
+
const cwd = cwdFromAssemble(ctx, asm)
|
|
519
|
+
const project = cwd ? readRulesSync(projectRulesDir(cwd), 'project') : []
|
|
520
|
+
return renderRulesSection(user, project, cwd ?? undefined)
|
|
521
|
+
} catch {
|
|
522
|
+
return ''
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
ctx.effect(() => sp.section({ name: SECTION_NAME, order: SECTION_ORDER, text: provider }), 'vscode-mode: rules section')
|
|
526
|
+
return true
|
|
527
|
+
}
|
|
528
|
+
// --endregion
|