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,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,142 @@
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
+ * 取一个 DSH 服务。
23
+ *
24
+ * **只能走 `ctx.get()`,不能走属性访问**:`ctx.agents` 这类属性访问在服务"存在但对该 fiber
25
+ * 不可达"时会抛 `cannot get property "agents" without inject`;这条路径跑在路由回调里,
26
+ * 抛了就变成 500,连"没有可用会话"的 409 提示都给不出来。
27
+ * (0.3.6 的线上事故同源:`bridge-tools.mjs` 里 `ctx.systemPrompt` 的属性访问让整棵插件树
28
+ * 加载失败、dsh web 起不来。)
29
+ */
30
+ function getService(ctx, name) {
31
+ if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') return undefined;
32
+ try {
33
+ return ctx.get(name);
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ /** 拼进消息的选区文本上限(超长时截断并注明)。 */
40
+ export const MAX_SELECTION_CHARS = 8000;
41
+
42
+ /** 供模型识别的文件引用文本(1 基行号,与 VS Code 一致)。 */
43
+ function locationText(file, lineStart, lineEnd) {
44
+ if (typeof file !== 'string' || file === '') return null;
45
+ if (!Number.isSafeInteger(lineStart)) return file;
46
+ if (!Number.isSafeInteger(lineEnd) || lineEnd === lineStart) return `${file}:${lineStart}`;
47
+ return `${file}:${lineStart}-${lineEnd}`;
48
+ }
49
+
50
+ /**
51
+ * 组消息文本。
52
+ *
53
+ * 结构刻意保持"人类可读 + 机器可抠":第一行是位置,代码块带语言标记,
54
+ * 最后是用户的原话 —— 这样即使模型没调任何工具,它也知道该看哪个文件。
55
+ *
56
+ * @param {{text: string, file: string|null, lineStart: number|null, lineEnd: number|null,
57
+ * languageId: string|null, selection: string|null}} input
58
+ */
59
+ export function composeEditorPrompt(input) {
60
+ const parts = [];
61
+ const location = locationText(input.file, input.lineStart, input.lineEnd);
62
+ if (location !== null) parts.push(`From the editor: ${location}`);
63
+ if (typeof input.selection === 'string' && input.selection !== '') {
64
+ const lang = typeof input.languageId === 'string' && input.languageId !== '' ? input.languageId : '';
65
+ const body = input.selection.length > MAX_SELECTION_CHARS
66
+ ? `${input.selection.slice(0, MAX_SELECTION_CHARS)}\n…(selection truncated)`
67
+ : input.selection;
68
+ parts.push(['```' + lang, body, '```'].join('\n'));
69
+ }
70
+ parts.push(input.text);
71
+ return parts.join('\n\n');
72
+ }
73
+
74
+ /**
75
+ * 选一个投递目标 agent。
76
+ * @param {object} ctx cordis 上下文
77
+ */
78
+ export function pickAgent(ctx) {
79
+ const agents = getService(ctx, 'agents');
80
+ if (agents === undefined || agents === null) return null;
81
+ try {
82
+ if (typeof agents.currentInitiator === 'function') {
83
+ const current = agents.currentInitiator();
84
+ if (current !== undefined && current !== null) return current;
85
+ }
86
+ } catch {
87
+ // 不在 initiator 边界内(正常:路由回调不在驱动链上)
88
+ }
89
+ try {
90
+ if (typeof agents.list === 'function') {
91
+ const list = agents.list();
92
+ if (Array.isArray(list) && list.length > 0) {
93
+ const running = list.filter((agent) => agent !== null && agent !== undefined && agent.status === 'running');
94
+ return running.length > 0 ? running[running.length - 1] : list[list.length - 1];
95
+ }
96
+ }
97
+ } catch {
98
+ // 探测失败 = 当作没有可用 agent
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * 投递一条编辑器消息。
105
+ *
106
+ * **永不抛**:返回结构化结果,由路由决定 HTTP 码。
107
+ * 所有失败路径都带上 `code`,便于编辑器侧给出可操作的提示。
108
+ *
109
+ * @param {object} ctx cordis 上下文
110
+ * @param {object} input `composeEditorPrompt` 的输入
111
+ * @returns {Promise<{ok: boolean, code?: string, error?: string, sessionId?: string, text?: string}>}
112
+ */
113
+ export async function deliverEditorPrompt(ctx, input) {
114
+ const createUserMessage = await loadDshExport('@deepseek-ai/dsh-llm', 'createUserMessage');
115
+ if (typeof createUserMessage !== 'function') {
116
+ return { ok: false, code: 'NO_LLM', error: '解析不到 @deepseek-ai/dsh-llm 的 createUserMessage(DSH 部署不完整?)' };
117
+ }
118
+ const agent = pickAgent(ctx);
119
+ if (agent === null || agent === undefined) {
120
+ return { ok: false, code: 'NO_AGENT', error: '没有可投递的会话:请先在 DSH 里打开或新建一个会话' };
121
+ }
122
+ const text = composeEditorPrompt(input);
123
+ let message;
124
+ try {
125
+ message = createUserMessage({
126
+ content: [{ type: 'text', text }],
127
+ source: { kind: 'plugin', plugin: SOURCE_PLUGIN, form: 'notice', summary: '来自编辑器' },
128
+ });
129
+ } catch (err) {
130
+ return { ok: false, code: 'BAD_MESSAGE', error: `构造消息失败:${err && err.message ? err.message : String(err)}` };
131
+ }
132
+ try {
133
+ // followup = 下一轮 + 唤醒:agent 空闲就立刻开一轮,在跑就排队,两种情况用户都能看到。
134
+ agent.followup(message);
135
+ } catch (err) {
136
+ return { ok: false, code: 'DELIVER_FAILED', error: `投递失败:${err && err.message ? err.message : String(err)}` };
137
+ }
138
+ const sessionId = agent.session !== undefined && agent.session !== null && typeof agent.session === 'object'
139
+ ? (agent.session.id ?? agent.id ?? null)
140
+ : (agent.id ?? null);
141
+ return { ok: true, sessionId, text };
142
+ }