dsh-code-server-app 0.2.14 → 0.3.7

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,374 @@
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
+ * 取一个 DSH 服务。
74
+ *
75
+ * **必须走 `ctx.get()`,不能走属性访问**(0.3.6 的真实线上事故:用 `ctx.systemPrompt`
76
+ * 让整棵插件树加载失败、dsh web 直接起不来)。
77
+ *
78
+ * 区别是确定的(cordis `src/reflect.ts`):
79
+ * - `ctx.get(name)` → `ReflectService.get()` → `_getImpl()`:服务没提供就返回 `undefined`,
80
+ * **永不抛**;
81
+ * - `ctx.tools` 这类属性访问 → 走代理的 get trap,服务"存在但对该 fiber 不可达"时
82
+ * 依次尝试 `internal/get` 瀑布 / `props[prop].get` / `reflect.get(prop,false)`,
83
+ * 任一失败都会抛 `cannot get property "x" without inject`
84
+ * —— 而那是在 `apply()` 里,loader 会因此判定 `failed to apply loader entry` 并终止整个 profile。
85
+ *
86
+ * 所以:`ctx.get()` + 判空 = 可选服务的正确姿势;属性访问只对**已声明 inject** 的服务安全。
87
+ */
88
+ function getService(ctx, name) {
89
+ if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') return undefined;
90
+ try {
91
+ return ctx.get(name);
92
+ } catch {
93
+ return undefined; // 连 get 都抛(上下文形态异常)时,退化为"没有这个服务"
94
+ }
95
+ }
96
+
97
+ /** 取一个服务上的方法,绑定好 this(避免调用时丢上下文)。 */
98
+ function getServiceMethod(ctx, serviceName, methodName) {
99
+ const service = getService(ctx, serviceName);
100
+ if (service === undefined || service === null || typeof service[methodName] !== 'function') return null;
101
+ return service[methodName].bind(service);
102
+ }
103
+
104
+ // ---------------------------------------------------------------- 工具值投影
105
+
106
+ /**
107
+ * 把 `/context` 的响应压成模型友好的短文本。
108
+ *
109
+ * 上限在**扩展侧**也有一份(它才是权威);这里再兜一层是因为模型看到的是这个字符串,
110
+ * 不能因为扩展版本不一致就把一整棵诊断树塞进上下文。
111
+ */
112
+ function renderContext(value) {
113
+ if (value.available !== true) {
114
+ return `编辑器上下文不可用:${value.reason ?? '未知原因'}(回退到直接读磁盘文件)`;
115
+ }
116
+ const lines = [];
117
+ const active = value.active ?? null;
118
+ if (active === null) {
119
+ lines.push('活动编辑器:无(用户没有聚焦任何文件)');
120
+ } else {
121
+ const sel = active.selection === null || active.selection === undefined
122
+ ? ''
123
+ : ` 选区 ${active.selection.startLine}:${active.selection.startColumn}-${active.selection.endLine}:${active.selection.endColumn}`;
124
+ lines.push(`活动编辑器:${active.path ?? active.name}${active.language ? ` (${active.language})` : ''}`
125
+ + `${active.dirty === true ? ' — 有未保存改动' : ''}${sel}`);
126
+ if (typeof active.selectedText === 'string' && active.selectedText !== '') {
127
+ lines.push('选中的文本:');
128
+ lines.push('```');
129
+ lines.push(active.selectedText.length > 4000 ? `${active.selectedText.slice(0, 4000)}\n…(已截断)` : active.selectedText);
130
+ lines.push('```');
131
+ }
132
+ }
133
+ const dirty = Array.isArray(value.dirtyBuffers) ? value.dirtyBuffers : [];
134
+ if (dirty.length === 0) {
135
+ lines.push('未保存缓冲区:无(磁盘内容 = 用户所见)');
136
+ } else {
137
+ lines.push(`未保存缓冲区(${dirty.length} 个,磁盘内容与用户所见不一致;不要直接覆盖):`);
138
+ for (const item of dirty.slice(0, 20)) {
139
+ lines.push(` - ${item.path ?? item.name}${typeof item.unsavedLines === 'number' ? `(+${item.unsavedLines} 行未保存)` : ''}`);
140
+ }
141
+ if (dirty.length > 20) lines.push(` …(还有 ${dirty.length - 20} 个)`);
142
+ }
143
+ const problems = Array.isArray(value.problems) ? value.problems : [];
144
+ if (problems.length === 0) {
145
+ lines.push('问题面板:无错误/警告');
146
+ } else {
147
+ lines.push('问题面板(按文件聚合,用 editor_diagnostics 看细节):');
148
+ for (const item of problems.slice(0, 30)) {
149
+ lines.push(` - ${item.path}:${item.line ?? ''} ${item.severity} ${item.message}`);
150
+ }
151
+ if (problems.length > 30) lines.push(` …(还有 ${problems.length - 30} 个)`);
152
+ }
153
+ if (typeof value.truncated === 'string' && value.truncated !== '') lines.push(`(注:${value.truncated})`);
154
+ return lines.join('\n');
155
+ }
156
+
157
+ /** 诊断结果 → 模型友好短文本(带 file:line,便于模型直接定位)。 */
158
+ function renderDiagnostics(value) {
159
+ if (value.available !== true) {
160
+ return `编辑器诊断不可用:${value.reason ?? '未知原因'}(回退到在你自己的终端里跑 tsc/eslint)`;
161
+ }
162
+ const items = Array.isArray(value.diagnostics) ? value.diagnostics : [];
163
+ if (items.length === 0) return '没有匹配的诊断(编辑器当前没有报错或警告)';
164
+ const lines = [`诊断 ${items.length} 条${typeof value.total === 'number' && value.total > items.length ? `(共 ${value.total},已截断)` : ''}:`];
165
+ for (const d of items) {
166
+ lines.push(`${d.path}:${d.line}:${d.column} [${d.severity}] ${d.message}${d.source ? ` (${d.source}${d.code ? ` ${d.code}` : ''})` : ''}`);
167
+ }
168
+ return lines.join('\n');
169
+ }
170
+
171
+ /** 桥不可用时的统一值(永不抛:工具失败会让模型重试,而"没接编辑器"不是错误)。 */
172
+ function unavailable(reason) {
173
+ return { available: false, reason, active: null, dirtyBuffers: [], problems: [], diagnostics: [], total: 0 };
174
+ }
175
+
176
+ /** 严重度排序权重(与扩展侧 lib/context-model.js 的 SEVERITY_RANK 一致)。 */
177
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2, hint: 3 };
178
+ const MAX_DIAGNOSTICS = 200;
179
+
180
+ /** 把缓存里的诊断树(`[{path, items:[{line,column,severity,message,source,code}]}]`)按入参过滤。 */
181
+ function projectDiagnostics(tree, args) {
182
+ const rows = [];
183
+ for (const group of tree) {
184
+ if (group === null || typeof group.path !== 'string') continue;
185
+ if (typeof args.file === 'string' && args.file !== '' && group.path !== args.file) continue;
186
+ for (const item of Array.isArray(group.items) ? group.items : []) {
187
+ const severity = typeof item.severity === 'string' ? item.severity : 'info';
188
+ if (typeof args.severity === 'string' && SEVERITY_RANK[severity] !== undefined && SEVERITY_RANK[severity] > SEVERITY_RANK[args.severity]) continue;
189
+ rows.push({
190
+ path: group.path,
191
+ line: Number.isSafeInteger(item.line) ? item.line : 1,
192
+ column: Number.isSafeInteger(item.column) ? item.column : 1,
193
+ severity,
194
+ message: typeof item.message === 'string' ? item.message : '',
195
+ ...(typeof item.source === 'string' && item.source !== '' ? { source: item.source } : {}),
196
+ ...(item.code === undefined || item.code === null || item.code === '' ? {} : { code: String(item.code) }),
197
+ });
198
+ }
199
+ }
200
+ rows.sort((a, b) => (SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])
201
+ || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
202
+ || (a.line - b.line));
203
+ const cap = Number.isSafeInteger(args.limit) && args.limit > 0 ? Math.min(args.limit, MAX_DIAGNOSTICS) : 100;
204
+ return { diagnostics: rows.slice(0, cap), total: rows.length, truncated: rows.length > cap };
205
+ }
206
+
207
+ /** 桥是否"活着":启用 + 扩展在最近一个 TTL 内上报过。 */
208
+ function bridgeLive(deps) {
209
+ const meta = deps.target();
210
+ if (meta === null || meta === undefined) return { live: false, reason: '编辑器桥未启用(IDE 没在运行,或 serve=dsh 不支持桥)' };
211
+ const cache = deps.cache();
212
+ if (cache === null || cache === undefined || cache.get() === null) {
213
+ return { live: false, reason: '编辑器里的扩展还没有上报状态(IDE 刚起?扩展被禁用了?)' };
214
+ }
215
+ if (cache.isStale()) {
216
+ const age = cache.ageMs();
217
+ return { live: false, reason: `编辑器状态已过期(${age === null ? '未知' : `${Math.round(age / 1000)}s`} 没更新;IDE 面板关掉了吗?)` };
218
+ }
219
+ return { live: true };
220
+ }
221
+
222
+ // ---------------------------------------------------------------- 注册
223
+
224
+ /**
225
+ * 注册编辑器工具,返回**同步** disposer。
226
+ *
227
+ * 由 `lib/index.js` 在桥进入 running 时调用、离开 running 时调用 disposer。
228
+ * `tools` 服务缺失 / `defineTool` 解析不到时返回 null(调用方据此退化为"只有 HTTP 面")。
229
+ *
230
+ * @param {object} ctx cordis 上下文(需要有 `tools` 服务)
231
+ * @param {{target: () => object|null, cache: () => object|null}} deps
232
+ * `target()` 取当前桥目标(null = 未启用);`cache()` 取编辑器状态缓存
233
+ * (见 lib/bridge.mjs 的 createContextCache —— 扩展在每次 /sync 里刷新它)。
234
+ */
235
+ export async function registerEditorTools(ctx, deps) {
236
+ const tools = getService(ctx, 'tools');
237
+ if (tools === undefined || tools === null || typeof tools.register !== 'function') return null;
238
+ const defineTool = await loadDefineTool();
239
+ if (defineTool === null) return null;
240
+ const register = tools.register.bind(tools);
241
+
242
+ const contextTool = defineTool({
243
+ name: EDITOR_CONTEXT_TOOL,
244
+ description: CONTEXT_DESCRIPTION,
245
+ parameters: {},
246
+ output: {
247
+ schema: {
248
+ type: 'object',
249
+ additionalProperties: true,
250
+ properties: {
251
+ available: { type: 'boolean', required: true },
252
+ reason: { type: 'string' },
253
+ active: { type: 'json' },
254
+ dirtyBuffers: { type: 'array', items: { type: 'json' } },
255
+ problems: { type: 'array', items: { type: 'json' } },
256
+ diagnostics: { type: 'array', items: { type: 'json' } },
257
+ total: { type: 'integer' },
258
+ },
259
+ },
260
+ render: (_args, value) => [{ type: 'text', text: renderContext(value) }],
261
+ },
262
+ async execute(_args, _exec) {
263
+ const status = bridgeLive(deps);
264
+ if (status.live !== true) return unavailable(status.reason);
265
+ const context = deps.cache().get().context;
266
+ return { ...context, available: true };
267
+ },
268
+ presentCall: () => ({ card: 'generic', title: '读取编辑器状态', kind: 'read' }),
269
+ });
270
+
271
+ const diagnosticsTool = defineTool({
272
+ name: EDITOR_DIAGNOSTICS_TOOL,
273
+ description: DIAGNOSTICS_DESCRIPTION,
274
+ parameters: {
275
+ file: { type: 'string', description: '可选:只看这个文件(绝对路径,必须已在编辑器的工作区内)' },
276
+ severity: {
277
+ type: 'string',
278
+ enum: ['error', 'warning', 'info', 'hint'],
279
+ description: '可选:只保留该严重度及以上(默认全部)',
280
+ },
281
+ limit: { type: 'integer', description: '可选:最多返回多少条(默认 100,上限 200)' },
282
+ },
283
+ output: {
284
+ schema: {
285
+ type: 'object',
286
+ additionalProperties: true,
287
+ properties: {
288
+ available: { type: 'boolean', required: true },
289
+ reason: { type: 'string' },
290
+ diagnostics: { type: 'array', items: { type: 'json' } },
291
+ total: { type: 'integer' },
292
+ truncated: { type: 'string' },
293
+ },
294
+ },
295
+ render: (_args, value) => [{ type: 'text', text: renderDiagnostics(value) }],
296
+ },
297
+ async execute(args, _exec) {
298
+ const status = bridgeLive(deps);
299
+ if (status.live !== true) return unavailable(status.reason);
300
+ const cached = deps.cache().get();
301
+ const projected = projectDiagnostics(cached.diagnostics, args);
302
+ return {
303
+ available: true,
304
+ diagnostics: projected.diagnostics,
305
+ total: projected.total,
306
+ truncated: projected.truncated ? `只返回前 ${projected.diagnostics.length} 条(共 ${projected.total})` : '',
307
+ };
308
+ },
309
+ presentCall: (args) => ({
310
+ card: 'generic',
311
+ title: args.file ? `读取诊断:${args.file}` : '读取诊断',
312
+ kind: 'read',
313
+ ...(args.file ? { locations: [{ path: args.file }] } : {}),
314
+ }),
315
+ });
316
+
317
+ const disposers = [register(contextTool), register(diagnosticsTool)];
318
+ return () => {
319
+ for (const dispose of disposers) {
320
+ try {
321
+ dispose();
322
+ } catch {
323
+ // 双保险:注册本身也挂在 fiber 上
324
+ }
325
+ }
326
+ };
327
+ }
328
+
329
+ /**
330
+ * 系统提示词段落里"桥是否可用"的同步探针。
331
+ *
332
+ * `PromptSection.text` 不支持 async 也不支持 `when` 谓词,所以只能用一个同步可读的开关:
333
+ * 由 `lib/index.js` 在桥状态变化时维护(`setPromptLiveProbe(() => bridgeLive)`),
334
+ * 段落文本按它返回整段或空串(空串会被 DSH 整个丢弃 —— 模型看不到"有一个用不了的工具")。
335
+ */
336
+ let promptLiveProbe = () => false;
337
+
338
+ /** 注入同步探针(桥 running 时为 true)。 */
339
+ export function setPromptLiveProbe(probe) {
340
+ promptLiveProbe = typeof probe === 'function' ? probe : () => false;
341
+ }
342
+
343
+ function bridgeIsLive() {
344
+ try {
345
+ return promptLiveProbe() === true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 注册系统提示词段落,返回 disposer(或 null = 该 DSH 没有 systemPrompt 服务)。
353
+ *
354
+ * **这里就是 0.3.6 线上事故的位置**:原实现写的是 `ctx?.systemPrompt ?? ctx.get(...)`,
355
+ * 而属性访问会抛(见 `getService` 的说明)—— 可选链只挡 null/undefined,挡不住抛错,
356
+ * 于是 `??` 右边的 `ctx.get()` 永远没机会执行,整个 profile 加载失败。
357
+ * 现在只走 `ctx.get()`,并且对 `section` 调用本身也加保护。
358
+ *
359
+ * @param {object} ctx cordis 上下文
360
+ */
361
+ export function registerEditorPrompt(ctx) {
362
+ const section = getServiceMethod(ctx, 'systemPrompt', 'section');
363
+ if (section === null) return null;
364
+ try {
365
+ return section({
366
+ name: PROMPT_SECTION,
367
+ order: PROMPT_ORDER,
368
+ text: () => (bridgeIsLive() ? PROMPT_TEXT : ''),
369
+ });
370
+ } catch (error) {
371
+ console.warn(`[code-server] 编辑器桥:提示词段落注册失败(不影响其余能力):${error && error.message ? error.message : error}`);
372
+ return null;
373
+ }
374
+ }