dsh-code-server-app 0.2.13 → 0.3.6

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,184 @@
1
+ /**
2
+ * lib/bridge-observe.mjs — 观察 agent 的写操作,并把结果送进编辑器(0.3.0)。
3
+ *
4
+ * 两个方向:
5
+ *
6
+ * **agent → 编辑器**(`tools/result`):把所有写类工具的落点推成"去看一眼这个文件"的事件。
7
+ * - 不限定 agent:`ctx.on('tools/result')` 注册在根上下文,而 `dsh-scope` 的载体过滤是
8
+ * `tag === undefined → true`,所以**一个监听器能看到所有 agent 与子 agent 的调用**;
9
+ * - 事件里**只带路径**,不带内容 —— 内容由扩展自己算(它才知道缓冲区里那一份),
10
+ * 这也让 host 侧不持有文件内容(少一处泄密面);
11
+ * - 有两条抽取路径,都必要:
12
+ * ① `result.meta.diffs`(`dsh-tool-fs` 的 write/edit 会带上 `FsDiffMeta`);
13
+ * ② 从 `exec.arguments.file_path` / `.path` 直接取(`str_replace_editor` **不带** meta,
14
+ * 在别的 profile 里它才是主力工具)。
15
+ * - `tools/result` 是 emit 型观察点:抛错不会影响调用结果(`notifyResult` 内部吞掉 listener
16
+ * 异常),所以这里出问题最坏就是"少一次 diff 提示",绝不会弄坏 agent 的一轮。
17
+ *
18
+ * **编辑器 → agent**(`tools/pre-execute`):写之前如果该文件在编辑器里是脏的,附一条提示。
19
+ * - 用 `exec.deferContext(...)` 而不是改写入参:`PreToolDecision` **明确排除了入参改写**
20
+ * (参数已经进日志与展示了),所以"提醒"是唯一正确的介入方式;
21
+ * - **不阻断**:误报的代价是模型看到一句提示,而阻断一个正确的编辑会让 agent 卡住;
22
+ * - 桥不可用/状态过期 → 直接放行,零开销。
23
+ */
24
+
25
+ import { loadDshExport } from './dsh-resolve.mjs';
26
+
27
+ /** 会改文件的工具名(各 profile 里的名字不同,所以按"名字 + 参数里有路径"双重判定)。 */
28
+ const WRITE_TOOLS = new Set(['write', 'edit', 'str_replace_editor', 'create_file', 'apply_patch', 'multi_edit']);
29
+
30
+ /** 提示词的来源标记(会话日志里能看出这条来自编辑器桥)。 */
31
+ const SOURCE_PLUGIN = 'dsh-code-server-app:editor-bridge';
32
+
33
+ /** 同一个文件在多少毫秒内不重复推事件(agent 连续改同一文件时避免刷屏)。 */
34
+ const DEBOUNCE_MS = 1500;
35
+
36
+ /**
37
+ * 从一次工具结果里抽出"被改动的文件路径"。
38
+ *
39
+ * @param {string} name 工具名
40
+ * @param {unknown} args 调用参数(可能含 file_path / path)
41
+ * @param {{meta?: unknown}} result 结果(maybe 带 FsDiffMeta)
42
+ * @returns {string[]} 绝对路径列表(去重)
43
+ */
44
+ export function extractEditedPaths(name, args, result) {
45
+ const paths = new Set();
46
+ // ① 结果元数据里的 diff(最准:`dsh-tool-fs` 的 write/edit 每个 hunk 一条)
47
+ const meta = result !== null && typeof result === 'object' ? result.meta : undefined;
48
+ if (meta !== null && typeof meta === 'object' && Array.isArray(meta.diffs)) {
49
+ for (const diff of meta.diffs) {
50
+ if (diff !== null && typeof diff === 'object' && typeof diff.path === 'string' && diff.path !== '') {
51
+ paths.add(diff.path);
52
+ }
53
+ }
54
+ }
55
+ // ② 参数里的路径(str_replace_editor 这类没有 meta 的工具靠这条)
56
+ if (paths.size === 0 && WRITE_TOOLS.has(name) && args !== null && typeof args === 'object') {
57
+ for (const key of ['file_path', 'path', 'file']) {
58
+ const value = args[key];
59
+ if (typeof value === 'string' && value !== '') {
60
+ paths.add(value);
61
+ break;
62
+ }
63
+ }
64
+ // str_replace_editor 的 `view` 不改文件 —— 别把只读调用也报成改动
65
+ if (name === 'str_replace_editor' && args.command === 'view') paths.clear();
66
+ }
67
+ return [...paths];
68
+ }
69
+
70
+ /**
71
+ * 注册观察器(返回同步 disposer)。
72
+ *
73
+ * @param {object} ctx cordis 上下文(需要能 `on`)
74
+ * @param {{
75
+ * emit: (kind: string, fields?: object) => void,
76
+ * context: () => object|null,
77
+ * isLive: () => boolean,
78
+ * }} deps
79
+ * `emit(kind, fields)` 把事件推进 host 的环形缓冲;
80
+ * `context()` 取编辑器状态缓存(可能为 null);
81
+ * `isLive()` 桥是否就绪(用于决定要不要做脏缓冲区检查)。
82
+ */
83
+ export function registerBridgeObserver(ctx, deps) {
84
+ if (ctx === undefined || ctx === null || typeof ctx.on !== 'function') return null;
85
+ const disposers = [];
86
+ /** path → 上次推送时间(去抖)。 */
87
+ const lastEmit = new Map();
88
+
89
+ const onResult = (exec, result) => {
90
+ try {
91
+ if (exec === null || exec === undefined) return;
92
+ const name = typeof exec.name === 'string' ? exec.name : '';
93
+ if (name === '') return;
94
+ const isError = result !== null && typeof result === 'object' && result.isError === true;
95
+ if (isError) return; // 失败的写没有落点,别把编辑器叫起来
96
+ const paths = extractEditedPaths(name, exec.arguments, result);
97
+ if (paths.length === 0) return;
98
+ const now = Date.now();
99
+ const sessionId = exec.agent !== undefined && exec.agent !== null && exec.agent.session !== undefined
100
+ ? (exec.agent.session.id ?? exec.agent.id ?? null)
101
+ : null;
102
+ for (const filePath of paths) {
103
+ const previous = lastEmit.get(filePath);
104
+ if (previous !== undefined && now - previous < DEBOUNCE_MS) continue;
105
+ lastEmit.set(filePath, now);
106
+ deps.emit('agent-edit', { path: filePath, tool: name, sessionId });
107
+ }
108
+ // 去抖表也要有界(长时间会话里文件数会涨)。
109
+ if (lastEmit.size > 512) {
110
+ for (const [key, at] of lastEmit) {
111
+ if (now - at > DEBOUNCE_MS * 10) lastEmit.delete(key);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ // emit 型观察点的异常会被 DSH 吞掉,这里自己记一条更清楚
116
+ console.warn(`[code-server] 编辑器桥:处理 tool/result 失败 ${err && err.message ? err.message : err}`);
117
+ }
118
+ };
119
+
120
+ const onPreExecute = async (exec, next) => {
121
+ const decision = await next();
122
+ try {
123
+ if (decision === null || decision === undefined || decision.kind !== 'allow') return decision;
124
+ if (!deps.isLive()) return decision;
125
+ const name = typeof exec?.name === 'string' ? exec.name : '';
126
+ if (!WRITE_TOOLS.has(name)) return decision;
127
+ const args = exec.arguments;
128
+ if (args === null || typeof args !== 'object') return decision;
129
+ const filePath = typeof args.file_path === 'string' ? args.file_path
130
+ : (typeof args.path === 'string' ? args.path : null);
131
+ if (filePath === null || filePath === '') return decision;
132
+ if (name === 'str_replace_editor' && args.command === 'view') return decision;
133
+ const cached = deps.context();
134
+ if (cached === null || cached === undefined || cached.context === null) return decision;
135
+ const dirty = Array.isArray(cached.context.dirtyBuffers) ? cached.context.dirtyBuffers : [];
136
+ const hit = dirty.find((item) => item !== null && item.path === filePath);
137
+ if (hit === undefined) return decision;
138
+ const createUserMessage = await loadDshCreateUserMessage();
139
+ if (typeof createUserMessage !== 'function') return decision;
140
+ if (typeof exec.deferContext !== 'function') return decision;
141
+ exec.deferContext(createUserMessage({
142
+ content: [{
143
+ type: 'text',
144
+ text: `Note from the editor: ${filePath} has UNSAVED changes in the VS Code buffer.`
145
+ + ' Writing it now will conflict with what the user sees; the editor will keep their buffer and show a diff.'
146
+ + ' Consider mentioning it, or asking them to save/discard first.',
147
+ }],
148
+ source: { kind: 'plugin', plugin: SOURCE_PLUGIN, form: 'notice', summary: '编辑器里有未保存改动' },
149
+ }));
150
+ } catch (err) {
151
+ // 提示失败绝不影响调用
152
+ console.warn(`[code-server] 编辑器桥:dirty 提示失败 ${err && err.message ? err.message : err}`);
153
+ }
154
+ return decision;
155
+ };
156
+
157
+ try {
158
+ disposers.push(ctx.on('tools/result', onResult));
159
+ } catch (err) {
160
+ console.warn(`[code-server] 编辑器桥:注册 tools/result 失败 ${err && err.message ? err.message : err}`);
161
+ }
162
+ try {
163
+ disposers.push(ctx.on('tools/pre-execute', onPreExecute));
164
+ } catch (err) {
165
+ console.warn(`[code-server] 编辑器桥:注册 tools/pre-execute 失败 ${err && err.message ? err.message : err}`);
166
+ }
167
+
168
+ return () => {
169
+ for (const dispose of disposers) {
170
+ try {
171
+ if (typeof dispose === 'function') dispose();
172
+ } catch {
173
+ // 忽略
174
+ }
175
+ }
176
+ };
177
+ }
178
+
179
+ /** 懒解析(与 bridge-tools 同一策略):解析不到就静默跳过提示。 */
180
+ let createUserMessagePromise = null;
181
+ function loadDshCreateUserMessage() {
182
+ createUserMessagePromise ??= loadDshExport('@deepseek-ai/dsh-llm', 'createUserMessage');
183
+ return createUserMessagePromise;
184
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * lib/bridge-session.mjs — 把编辑器里的动作投递成 DSH 的一条用户消息(0.3.0)。
3
+ *
4
+ * 方向:编辑器 → DSH。用户在编辑器里选中一段代码、说一句"这个函数为什么是错的",
5
+ * 这句话应当出现在**当前会话**里,并且带上文件:行与选区文本。
6
+ *
7
+ * 投递目标的选取顺序(都通过 `ctx.get(...)` 特性探测,DSH 版本差异不会炸主流程):
8
+ * 1. `ctx.agents.currentInitiator()` —— 正在跑的那次驱动链的 agent(最贴近"当前会话");
9
+ * 2. `ctx.agents.list()` —— 有 running 的取 running,否则取列表里最后一个(通常是最新的);
10
+ * 3. 都没有 → `{ok:false, code:'NO_AGENT'}`(编辑器侧据此提示"先在 DSH 里打开一个会话")。
11
+ *
12
+ * 投递方式是 `agent.followup(message)`(= `send(msg, 'next-turn', true)`):
13
+ * agent 空闲则立刻开一轮,正在跑则排在下一轮 —— 两种情况下用户都能看到自己的话进了对话。
14
+ */
15
+
16
+ import { loadDshExport } from './dsh-resolve.mjs';
17
+
18
+ /** 消息来源标记:在会话日志里能一眼看出这条来自编辑器桥。 */
19
+ export const SOURCE_PLUGIN = 'dsh-code-server-app:editor-bridge';
20
+
21
+ /** 拼进消息的选区文本上限(超长时截断并注明)。 */
22
+ export const MAX_SELECTION_CHARS = 8000;
23
+
24
+ /** 供模型识别的文件引用文本(1 基行号,与 VS Code 一致)。 */
25
+ function locationText(file, lineStart, lineEnd) {
26
+ if (typeof file !== 'string' || file === '') return null;
27
+ if (!Number.isSafeInteger(lineStart)) return file;
28
+ if (!Number.isSafeInteger(lineEnd) || lineEnd === lineStart) return `${file}:${lineStart}`;
29
+ return `${file}:${lineStart}-${lineEnd}`;
30
+ }
31
+
32
+ /**
33
+ * 组消息文本。
34
+ *
35
+ * 结构刻意保持"人类可读 + 机器可抠":第一行是位置,代码块带语言标记,
36
+ * 最后是用户的原话 —— 这样即使模型没调任何工具,它也知道该看哪个文件。
37
+ *
38
+ * @param {{text: string, file: string|null, lineStart: number|null, lineEnd: number|null,
39
+ * languageId: string|null, selection: string|null}} input
40
+ */
41
+ export function composeEditorPrompt(input) {
42
+ const parts = [];
43
+ const location = locationText(input.file, input.lineStart, input.lineEnd);
44
+ if (location !== null) parts.push(`From the editor: ${location}`);
45
+ if (typeof input.selection === 'string' && input.selection !== '') {
46
+ const lang = typeof input.languageId === 'string' && input.languageId !== '' ? input.languageId : '';
47
+ const body = input.selection.length > MAX_SELECTION_CHARS
48
+ ? `${input.selection.slice(0, MAX_SELECTION_CHARS)}\n…(selection truncated)`
49
+ : input.selection;
50
+ parts.push(['```' + lang, body, '```'].join('\n'));
51
+ }
52
+ parts.push(input.text);
53
+ return parts.join('\n\n');
54
+ }
55
+
56
+ /**
57
+ * 选一个投递目标 agent。
58
+ * @param {object} ctx cordis 上下文
59
+ */
60
+ export function pickAgent(ctx) {
61
+ const agents = ctx?.agents ?? (typeof ctx?.get === 'function' ? ctx.get('agents') : undefined);
62
+ if (agents === undefined || agents === null) return null;
63
+ try {
64
+ if (typeof agents.currentInitiator === 'function') {
65
+ const current = agents.currentInitiator();
66
+ if (current !== undefined && current !== null) return current;
67
+ }
68
+ } catch {
69
+ // 不在 initiator 边界内(正常:路由回调不在驱动链上)
70
+ }
71
+ try {
72
+ if (typeof agents.list === 'function') {
73
+ const list = agents.list();
74
+ if (Array.isArray(list) && list.length > 0) {
75
+ const running = list.filter((agent) => agent !== null && agent !== undefined && agent.status === 'running');
76
+ return running.length > 0 ? running[running.length - 1] : list[list.length - 1];
77
+ }
78
+ }
79
+ } catch {
80
+ // 探测失败 = 当作没有可用 agent
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * 投递一条编辑器消息。
87
+ *
88
+ * **永不抛**:返回结构化结果,由路由决定 HTTP 码。
89
+ * 所有失败路径都带上 `code`,便于编辑器侧给出可操作的提示。
90
+ *
91
+ * @param {object} ctx cordis 上下文
92
+ * @param {object} input `composeEditorPrompt` 的输入
93
+ * @returns {Promise<{ok: boolean, code?: string, error?: string, sessionId?: string, text?: string}>}
94
+ */
95
+ export async function deliverEditorPrompt(ctx, input) {
96
+ const createUserMessage = await loadDshExport('@deepseek-ai/dsh-llm', 'createUserMessage');
97
+ if (typeof createUserMessage !== 'function') {
98
+ return { ok: false, code: 'NO_LLM', error: '解析不到 @deepseek-ai/dsh-llm 的 createUserMessage(DSH 部署不完整?)' };
99
+ }
100
+ const agent = pickAgent(ctx);
101
+ if (agent === null || agent === undefined) {
102
+ return { ok: false, code: 'NO_AGENT', error: '没有可投递的会话:请先在 DSH 里打开或新建一个会话' };
103
+ }
104
+ const text = composeEditorPrompt(input);
105
+ let message;
106
+ try {
107
+ message = createUserMessage({
108
+ content: [{ type: 'text', text }],
109
+ source: { kind: 'plugin', plugin: SOURCE_PLUGIN, form: 'notice', summary: '来自编辑器' },
110
+ });
111
+ } catch (err) {
112
+ return { ok: false, code: 'BAD_MESSAGE', error: `构造消息失败:${err && err.message ? err.message : String(err)}` };
113
+ }
114
+ try {
115
+ // followup = 下一轮 + 唤醒:agent 空闲就立刻开一轮,在跑就排队,两种情况用户都能看到。
116
+ agent.followup(message);
117
+ } catch (err) {
118
+ return { ok: false, code: 'DELIVER_FAILED', error: `投递失败:${err && err.message ? err.message : String(err)}` };
119
+ }
120
+ const sessionId = agent.session !== undefined && agent.session !== null && typeof agent.session === 'object'
121
+ ? (agent.session.id ?? agent.id ?? null)
122
+ : (agent.id ?? null);
123
+ return { ok: true, sessionId, text };
124
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * lib/bridge-tools.mjs — 编辑器桥的 agent 侧接口(0.3.0)。
3
+ *
4
+ * 两个**只读**工具 + 一段系统提示词说明:
5
+ * - `editor_context` :编辑器当前状态(活动文件/选区、未保存缓冲区、诊断计数)
6
+ * - `editor_diagnostics` :按严重度排序的诊断(可只问一个文件)
7
+ *
8
+ * 为什么是工具而不是"每步注入":
9
+ * `agent/pre-step` 每步都会跑,把编辑器状态无条件塞进上下文会让每个请求都变重且多半无关。
10
+ * 工具化 = 按需、有界、可被模型自己取舍。提示词只在桥可用时渲染(函数式 `text` 返回空串
11
+ * 会被 DSH 丢弃),所以 IDE 没起来时模型完全看不到这套东西。
12
+ *
13
+ * 为什么只在桥存活时注册:
14
+ * 桥不可用时注册会留下"永远不可用"的工具,模型会反复试。注销掉更干净 —— `tools/change`
15
+ * 会让客户端刷新工具集。若实测发现客户端对工具集变化处理不佳,把 `registerEditorTools`
16
+ * 改成"始终注册 + execute 里返回不可用说明"即可(单点开关)。
17
+ *
18
+ * 依赖注入:`defineTool` 从 DSH 部署里解析(见 `loadDefineTool`),不引入新的包依赖。
19
+ */
20
+
21
+ import { loadDshExport } from './dsh-resolve.mjs';
22
+
23
+ /** 工具名(模型可见)。 */
24
+ export const EDITOR_CONTEXT_TOOL = 'editor_context';
25
+ export const EDITOR_DIAGNOSTICS_TOOL = 'editor_diagnostics';
26
+
27
+ /** systemPrompt 段名与排序位置:靠后(在工具说明之后、运行时上下文之前)。 */
28
+ export const PROMPT_SECTION = 'code-server:editor-bridge';
29
+ export const PROMPT_ORDER = 4500;
30
+
31
+ /** 提示词全文(仅桥可用时渲染)。 */
32
+ export const PROMPT_TEXT = [
33
+ '## Editor bridge (VS Code / code-server)',
34
+ '',
35
+ 'A VS Code workbench is running next to this session and can be queried read-only:',
36
+ '',
37
+ '- `editor_context` — what the user is looking at right now: active file and selection, which',
38
+ ' buffers have UNSAVED changes, and how many problems each file has.',
39
+ '- `editor_diagnostics` — errors/warnings with file:line, produced by the real language servers',
40
+ ' (TypeScript, ESLint, …). Prefer this over guessing: it is cheaper and more accurate than',
41
+ ' re-reading whole files.',
42
+ '',
43
+ 'Use them when the user mentions "this file", "my selection", "the error I see", or when a change',
44
+ 'must not clobber unsaved edits. If a file has unsaved changes in the editor, the on-disk content',
45
+ 'differs from what the user sees — say so instead of silently overwriting it. Both tools are',
46
+ 'read-only: they never edit files or run commands.',
47
+ ].join('\n');
48
+
49
+ /** 工具描述(内联,避免 z.string().default 那种"必须重启才生效"的配置面)。 */
50
+ const CONTEXT_DESCRIPTION = [
51
+ 'Read the current state of the VS Code editor: active file + selection, open buffers with unsaved',
52
+ 'changes, and problem counts per file. Read-only. Returns {"available":false} when no editor is',
53
+ 'attached (then fall back to reading files from disk).',
54
+ ].join(' ');
55
+
56
+ const DIAGNOSTICS_DESCRIPTION = [
57
+ 'Read errors/warnings reported by the editor\'s language servers (the Problems panel), newest',
58
+ 'state, sorted by severity. Optionally narrow to one file. Read-only. Returns',
59
+ '{"available":false} when no editor is attached.',
60
+ ].join(' ');
61
+
62
+ // ---------------------------------------------------------------- DSH 依赖懒解析
63
+
64
+ /**
65
+ * 解析 `defineTool`(解析策略见 lib/dsh-resolve.mjs)。
66
+ * 解析不到 = 当前 DSH 没有工具服务 → 返回 null,桥退化为"只有 HTTP 面",不影响主流程。
67
+ */
68
+ async function loadDefineTool() {
69
+ return loadDshExport('@deepseek-ai/dsh-tools', 'defineTool');
70
+ }
71
+
72
+ // ---------------------------------------------------------------- 工具值投影
73
+
74
+ /**
75
+ * 把 `/context` 的响应压成模型友好的短文本。
76
+ *
77
+ * 上限在**扩展侧**也有一份(它才是权威);这里再兜一层是因为模型看到的是这个字符串,
78
+ * 不能因为扩展版本不一致就把一整棵诊断树塞进上下文。
79
+ */
80
+ function renderContext(value) {
81
+ if (value.available !== true) {
82
+ return `编辑器上下文不可用:${value.reason ?? '未知原因'}(回退到直接读磁盘文件)`;
83
+ }
84
+ const lines = [];
85
+ const active = value.active ?? null;
86
+ if (active === null) {
87
+ lines.push('活动编辑器:无(用户没有聚焦任何文件)');
88
+ } else {
89
+ const sel = active.selection === null || active.selection === undefined
90
+ ? ''
91
+ : ` 选区 ${active.selection.startLine}:${active.selection.startColumn}-${active.selection.endLine}:${active.selection.endColumn}`;
92
+ lines.push(`活动编辑器:${active.path ?? active.name}${active.language ? ` (${active.language})` : ''}`
93
+ + `${active.dirty === true ? ' — 有未保存改动' : ''}${sel}`);
94
+ if (typeof active.selectedText === 'string' && active.selectedText !== '') {
95
+ lines.push('选中的文本:');
96
+ lines.push('```');
97
+ lines.push(active.selectedText.length > 4000 ? `${active.selectedText.slice(0, 4000)}\n…(已截断)` : active.selectedText);
98
+ lines.push('```');
99
+ }
100
+ }
101
+ const dirty = Array.isArray(value.dirtyBuffers) ? value.dirtyBuffers : [];
102
+ if (dirty.length === 0) {
103
+ lines.push('未保存缓冲区:无(磁盘内容 = 用户所见)');
104
+ } else {
105
+ lines.push(`未保存缓冲区(${dirty.length} 个,磁盘内容与用户所见不一致;不要直接覆盖):`);
106
+ for (const item of dirty.slice(0, 20)) {
107
+ lines.push(` - ${item.path ?? item.name}${typeof item.unsavedLines === 'number' ? `(+${item.unsavedLines} 行未保存)` : ''}`);
108
+ }
109
+ if (dirty.length > 20) lines.push(` …(还有 ${dirty.length - 20} 个)`);
110
+ }
111
+ const problems = Array.isArray(value.problems) ? value.problems : [];
112
+ if (problems.length === 0) {
113
+ lines.push('问题面板:无错误/警告');
114
+ } else {
115
+ lines.push('问题面板(按文件聚合,用 editor_diagnostics 看细节):');
116
+ for (const item of problems.slice(0, 30)) {
117
+ lines.push(` - ${item.path}:${item.line ?? ''} ${item.severity} ${item.message}`);
118
+ }
119
+ if (problems.length > 30) lines.push(` …(还有 ${problems.length - 30} 个)`);
120
+ }
121
+ if (typeof value.truncated === 'string' && value.truncated !== '') lines.push(`(注:${value.truncated})`);
122
+ return lines.join('\n');
123
+ }
124
+
125
+ /** 诊断结果 → 模型友好短文本(带 file:line,便于模型直接定位)。 */
126
+ function renderDiagnostics(value) {
127
+ if (value.available !== true) {
128
+ return `编辑器诊断不可用:${value.reason ?? '未知原因'}(回退到在你自己的终端里跑 tsc/eslint)`;
129
+ }
130
+ const items = Array.isArray(value.diagnostics) ? value.diagnostics : [];
131
+ if (items.length === 0) return '没有匹配的诊断(编辑器当前没有报错或警告)';
132
+ const lines = [`诊断 ${items.length} 条${typeof value.total === 'number' && value.total > items.length ? `(共 ${value.total},已截断)` : ''}:`];
133
+ for (const d of items) {
134
+ lines.push(`${d.path}:${d.line}:${d.column} [${d.severity}] ${d.message}${d.source ? ` (${d.source}${d.code ? ` ${d.code}` : ''})` : ''}`);
135
+ }
136
+ return lines.join('\n');
137
+ }
138
+
139
+ /** 桥不可用时的统一值(永不抛:工具失败会让模型重试,而"没接编辑器"不是错误)。 */
140
+ function unavailable(reason) {
141
+ return { available: false, reason, active: null, dirtyBuffers: [], problems: [], diagnostics: [], total: 0 };
142
+ }
143
+
144
+ /** 严重度排序权重(与扩展侧 lib/context-model.js 的 SEVERITY_RANK 一致)。 */
145
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2, hint: 3 };
146
+ const MAX_DIAGNOSTICS = 200;
147
+
148
+ /** 把缓存里的诊断树(`[{path, items:[{line,column,severity,message,source,code}]}]`)按入参过滤。 */
149
+ function projectDiagnostics(tree, args) {
150
+ const rows = [];
151
+ for (const group of tree) {
152
+ if (group === null || typeof group.path !== 'string') continue;
153
+ if (typeof args.file === 'string' && args.file !== '' && group.path !== args.file) continue;
154
+ for (const item of Array.isArray(group.items) ? group.items : []) {
155
+ const severity = typeof item.severity === 'string' ? item.severity : 'info';
156
+ if (typeof args.severity === 'string' && SEVERITY_RANK[severity] !== undefined && SEVERITY_RANK[severity] > SEVERITY_RANK[args.severity]) continue;
157
+ rows.push({
158
+ path: group.path,
159
+ line: Number.isSafeInteger(item.line) ? item.line : 1,
160
+ column: Number.isSafeInteger(item.column) ? item.column : 1,
161
+ severity,
162
+ message: typeof item.message === 'string' ? item.message : '',
163
+ ...(typeof item.source === 'string' && item.source !== '' ? { source: item.source } : {}),
164
+ ...(item.code === undefined || item.code === null || item.code === '' ? {} : { code: String(item.code) }),
165
+ });
166
+ }
167
+ }
168
+ rows.sort((a, b) => (SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])
169
+ || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
170
+ || (a.line - b.line));
171
+ const cap = Number.isSafeInteger(args.limit) && args.limit > 0 ? Math.min(args.limit, MAX_DIAGNOSTICS) : 100;
172
+ return { diagnostics: rows.slice(0, cap), total: rows.length, truncated: rows.length > cap };
173
+ }
174
+
175
+ /** 桥是否"活着":启用 + 扩展在最近一个 TTL 内上报过。 */
176
+ function bridgeLive(deps) {
177
+ const meta = deps.target();
178
+ if (meta === null || meta === undefined) return { live: false, reason: '编辑器桥未启用(IDE 没在运行,或 serve=dsh 不支持桥)' };
179
+ const cache = deps.cache();
180
+ if (cache === null || cache === undefined || cache.get() === null) {
181
+ return { live: false, reason: '编辑器里的扩展还没有上报状态(IDE 刚起?扩展被禁用了?)' };
182
+ }
183
+ if (cache.isStale()) {
184
+ const age = cache.ageMs();
185
+ return { live: false, reason: `编辑器状态已过期(${age === null ? '未知' : `${Math.round(age / 1000)}s`} 没更新;IDE 面板关掉了吗?)` };
186
+ }
187
+ return { live: true };
188
+ }
189
+
190
+ // ---------------------------------------------------------------- 注册
191
+
192
+ /**
193
+ * 注册编辑器工具,返回**同步** disposer。
194
+ *
195
+ * 由 `lib/index.js` 在桥进入 running 时调用、离开 running 时调用 disposer。
196
+ * `tools` 服务缺失 / `defineTool` 解析不到时返回 null(调用方据此退化为"只有 HTTP 面")。
197
+ *
198
+ * @param {object} ctx cordis 上下文(需要有 `tools` 服务)
199
+ * @param {{target: () => object|null, cache: () => object|null}} deps
200
+ * `target()` 取当前桥目标(null = 未启用);`cache()` 取编辑器状态缓存
201
+ * (见 lib/bridge.mjs 的 createContextCache —— 扩展在每次 /sync 里刷新它)。
202
+ */
203
+ export async function registerEditorTools(ctx, deps) {
204
+ const tools = ctx?.tools ?? (typeof ctx?.get === 'function' ? ctx.get('tools') : undefined);
205
+ if (tools === undefined || tools === null || typeof tools.register !== 'function') return null;
206
+ const defineTool = await loadDefineTool();
207
+ if (defineTool === null) return null;
208
+
209
+ const contextTool = defineTool({
210
+ name: EDITOR_CONTEXT_TOOL,
211
+ description: CONTEXT_DESCRIPTION,
212
+ parameters: {},
213
+ output: {
214
+ schema: {
215
+ type: 'object',
216
+ additionalProperties: true,
217
+ properties: {
218
+ available: { type: 'boolean', required: true },
219
+ reason: { type: 'string' },
220
+ active: { type: 'json' },
221
+ dirtyBuffers: { type: 'array', items: { type: 'json' } },
222
+ problems: { type: 'array', items: { type: 'json' } },
223
+ diagnostics: { type: 'array', items: { type: 'json' } },
224
+ total: { type: 'integer' },
225
+ },
226
+ },
227
+ render: (_args, value) => [{ type: 'text', text: renderContext(value) }],
228
+ },
229
+ async execute(_args, _exec) {
230
+ const status = bridgeLive(deps);
231
+ if (status.live !== true) return unavailable(status.reason);
232
+ const context = deps.cache().get().context;
233
+ return { ...context, available: true };
234
+ },
235
+ presentCall: () => ({ card: 'generic', title: '读取编辑器状态', kind: 'read' }),
236
+ });
237
+
238
+ const diagnosticsTool = defineTool({
239
+ name: EDITOR_DIAGNOSTICS_TOOL,
240
+ description: DIAGNOSTICS_DESCRIPTION,
241
+ parameters: {
242
+ file: { type: 'string', description: '可选:只看这个文件(绝对路径,必须已在编辑器的工作区内)' },
243
+ severity: {
244
+ type: 'string',
245
+ enum: ['error', 'warning', 'info', 'hint'],
246
+ description: '可选:只保留该严重度及以上(默认全部)',
247
+ },
248
+ limit: { type: 'integer', description: '可选:最多返回多少条(默认 100,上限 200)' },
249
+ },
250
+ output: {
251
+ schema: {
252
+ type: 'object',
253
+ additionalProperties: true,
254
+ properties: {
255
+ available: { type: 'boolean', required: true },
256
+ reason: { type: 'string' },
257
+ diagnostics: { type: 'array', items: { type: 'json' } },
258
+ total: { type: 'integer' },
259
+ truncated: { type: 'string' },
260
+ },
261
+ },
262
+ render: (_args, value) => [{ type: 'text', text: renderDiagnostics(value) }],
263
+ },
264
+ async execute(args, _exec) {
265
+ const status = bridgeLive(deps);
266
+ if (status.live !== true) return unavailable(status.reason);
267
+ const cached = deps.cache().get();
268
+ const projected = projectDiagnostics(cached.diagnostics, args);
269
+ return {
270
+ available: true,
271
+ diagnostics: projected.diagnostics,
272
+ total: projected.total,
273
+ truncated: projected.truncated ? `只返回前 ${projected.diagnostics.length} 条(共 ${projected.total})` : '',
274
+ };
275
+ },
276
+ presentCall: (args) => ({
277
+ card: 'generic',
278
+ title: args.file ? `读取诊断:${args.file}` : '读取诊断',
279
+ kind: 'read',
280
+ ...(args.file ? { locations: [{ path: args.file }] } : {}),
281
+ }),
282
+ });
283
+
284
+ const disposers = [tools.register(contextTool), tools.register(diagnosticsTool)];
285
+ return () => {
286
+ for (const dispose of disposers) {
287
+ try {
288
+ dispose();
289
+ } catch {
290
+ // 双保险:注册本身也挂在 fiber 上
291
+ }
292
+ }
293
+ };
294
+ }
295
+
296
+ /**
297
+ * 系统提示词段落里"桥是否可用"的同步探针。
298
+ *
299
+ * `PromptSection.text` 不支持 async 也不支持 `when` 谓词,所以只能用一个同步可读的开关:
300
+ * 由 `lib/index.js` 在桥状态变化时维护(`setPromptLiveProbe(() => bridgeLive)`),
301
+ * 段落文本按它返回整段或空串(空串会被 DSH 整个丢弃 —— 模型看不到"有一个用不了的工具")。
302
+ */
303
+ let promptLiveProbe = () => false;
304
+
305
+ /** 注入同步探针(桥 running 时为 true)。 */
306
+ export function setPromptLiveProbe(probe) {
307
+ promptLiveProbe = typeof probe === 'function' ? probe : () => false;
308
+ }
309
+
310
+ function bridgeIsLive() {
311
+ try {
312
+ return promptLiveProbe() === true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * 注册系统提示词段落,返回 disposer(或 null = 该 DSH 没有 systemPrompt 服务)。
320
+ * @param {object} ctx cordis 上下文
321
+ */
322
+ export function registerEditorPrompt(ctx) {
323
+ const systemPrompt = ctx?.systemPrompt ?? (typeof ctx?.get === 'function' ? ctx.get('systemPrompt') : undefined);
324
+ if (systemPrompt === undefined || systemPrompt === null || typeof systemPrompt.section !== 'function') return null;
325
+ return systemPrompt.section({
326
+ name: PROMPT_SECTION,
327
+ order: PROMPT_ORDER,
328
+ text: () => (bridgeIsLive() ? PROMPT_TEXT : ''),
329
+ });
330
+ }