@xmanrui/dsh-im 4.32.0 → 4.34.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.
- package/PROACTIVE_DELIVERY.en.md +11 -0
- package/PROACTIVE_DELIVERY.md +11 -0
- package/README.en.md +11 -7
- package/README.md +11 -7
- package/lib/client.js +3 -71
- package/lib/index.js +298 -295
- package/package.json +1 -71
- package/plugin-src/host/delivery-adapter.mjs +8 -0
- package/plugin-src/host/delivery-service.mjs +75 -0
- package/plugin-src/host/host-language.mjs +21 -21
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/host/modern-harness-api.mjs +22 -0
- package/plugin-src/host/session-sync-coordinator.mjs +10 -13
- package/src/channels/feishu/bridge.mjs +87 -7
- package/src/channels/feishu/feishu-cards.mjs +29 -1
- package/src/channels/feishu/message-utils.mjs +61 -64
- package/src/channels/feishu/multi-bot-controller.mjs +59 -5
- package/src/channels/feishu/resource-download.mjs +120 -0
- package/src/channels/feishu/slash-command-panel.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +6 -1
- package/src/channels/shared/bot-workspace-store.mjs +5 -0
- package/src/channels/shared/command-list-snapshots.mjs +33 -0
- package/src/channels/shared/harness-client.mjs +11 -4
- package/src/channels/shared/i18n-en/feishu.mjs +7 -0
- package/src/channels/shared/i18n-en/permission.mjs +30 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/permission-command.mjs +149 -0
- package/src/channels/shared/preset-command.mjs +6 -51
- package/src/channels/shared/semantic/artifact.mjs +88 -18
- package/src/channels/telegram/telegram-runtime.mjs +12 -26
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createCommandListSnapshots } from './command-list-snapshots.mjs';
|
|
2
|
+
import { t } from './i18n.mjs';
|
|
3
|
+
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
|
4
|
+
import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
|
|
5
|
+
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
|
6
|
+
|
|
7
|
+
const COMMAND = /^\/(?:permission|permissionlist)(?=$|\s)/iu;
|
|
8
|
+
const SNAPSHOTS = createCommandListSnapshots();
|
|
9
|
+
const USAGE = '用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。';
|
|
10
|
+
const SCOPE = '仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。';
|
|
11
|
+
const REFRESH = '请先执行 /permissionlist,再按列表序号选择权限档位。';
|
|
12
|
+
const validId = (id) => typeof id === 'string' && /^[^\s\p{Cc}\p{Cf}]+$/u.test(id);
|
|
13
|
+
const display = (value) => typeof value === 'string'
|
|
14
|
+
? value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').trim() : '';
|
|
15
|
+
|
|
16
|
+
function result(message) {
|
|
17
|
+
return { handled: true, message, messages: splitWorkspaceCommandMessage(message) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function permissions(value) {
|
|
21
|
+
if (!value || !validId(value.currentValue) || !Array.isArray(value.options)
|
|
22
|
+
|| value.options.some((item) => !item || !validId(item.value))) {
|
|
23
|
+
throw new TypeError('Harness returned invalid permissions');
|
|
24
|
+
}
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
return {
|
|
27
|
+
currentValue: value.currentValue,
|
|
28
|
+
options: value.options.filter((item) => {
|
|
29
|
+
if (item.value === 'custom' || seen.has(item.value)) return false;
|
|
30
|
+
seen.add(item.value);
|
|
31
|
+
return true;
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function description(item) {
|
|
37
|
+
// Translate known Host copy, not an assumed ID -> policy mapping: presets are configurable.
|
|
38
|
+
const text = display(item.description);
|
|
39
|
+
if (text === 'Full file access without approval prompts.') {
|
|
40
|
+
return t('完全文件访问,不再请求审批。');
|
|
41
|
+
}
|
|
42
|
+
if (text === 'Write inside the workspace and permitted temporary directories; wider retries require approval.') {
|
|
43
|
+
return t('可写工作区和允许的临时目录;扩大访问范围需要审批。');
|
|
44
|
+
}
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorMessage(error, writing) {
|
|
49
|
+
const code = error?.code ?? error?.failure?.code;
|
|
50
|
+
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
|
51
|
+
return t('工作区或机器人状态已发生变化,请重试。');
|
|
52
|
+
}
|
|
53
|
+
if (['session-not-found', 'session/not-found', 'agent-not-found'].includes(code)) {
|
|
54
|
+
return t('当前聊天绑定的会话已不存在,请先绑定或建立会话。');
|
|
55
|
+
}
|
|
56
|
+
if (['commands-unavailable', 'permissions-unavailable', 'harness-api-not-found',
|
|
57
|
+
'endpoint-not-found', 'gateway/endpoint-not-found'].includes(code)) {
|
|
58
|
+
return t('当前 Host 暂不支持从机器人查看或切换权限档位。');
|
|
59
|
+
}
|
|
60
|
+
return writing
|
|
61
|
+
? t('未能确认权限切换结果,请执行 /permission 核对当前档位。')
|
|
62
|
+
: t('暂时无法获取权限档位,请稍后重试。');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isPermissionCommand(text) {
|
|
66
|
+
return typeof text === 'string' && COMMAND.test(text.trim());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runPermissionCommand(text, harness, state, key, options = {}) {
|
|
70
|
+
if (!isPermissionCommand(text)) return null;
|
|
71
|
+
if (options.hasImages || options.hasFiles) {
|
|
72
|
+
return result(t('权限命令仅支持纯文字,请移除图片或文件后重试。'));
|
|
73
|
+
}
|
|
74
|
+
const match = /^\/(permission|permissionlist)(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(text.trim());
|
|
75
|
+
if (!match || (match[1].toLowerCase() === 'permissionlist' && match[2])) return result(t(USAGE));
|
|
76
|
+
const list = match[1].toLowerCase() === 'permissionlist';
|
|
77
|
+
const requested = match[2];
|
|
78
|
+
if (requested && (!validId(requested) || requested.startsWith('--'))) return result(t(USAGE));
|
|
79
|
+
const signal = options.signal
|
|
80
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000);
|
|
81
|
+
let writing = false;
|
|
82
|
+
try {
|
|
83
|
+
return await withSessionBindingLock(state, key, async () => {
|
|
84
|
+
signal.throwIfAborted();
|
|
85
|
+
const sessionId = state.sessionFor?.(key);
|
|
86
|
+
if (!sessionId) return result(t('当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。'));
|
|
87
|
+
const session = typeof harness.workspaceSession === 'function'
|
|
88
|
+
? harness.workspaceSession(sessionId, key)
|
|
89
|
+
: {
|
|
90
|
+
permissions: harness.getSessionPermissions && ((opts) => harness.getSessionPermissions(sessionId, opts)),
|
|
91
|
+
executeCommand: harness.executeCommand && ((line, opts) => harness.executeCommand(sessionId, line, opts)),
|
|
92
|
+
};
|
|
93
|
+
if (!requested) {
|
|
94
|
+
if (typeof session?.permissions !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
|
|
95
|
+
const current = permissions(await session.permissions({ signal }));
|
|
96
|
+
if (state.sessionFor(key) !== sessionId) return result(t('当前会话已变化,请重新执行 /permissionlist。'));
|
|
97
|
+
const lines = [t('当前会话权限:{id}', { id: current.currentValue }), ''];
|
|
98
|
+
if (list) {
|
|
99
|
+
SNAPSHOTS.save(state, key, { sessionId, ids: current.options.map((item) => item.value) });
|
|
100
|
+
for (const [index, item] of current.options.entries()) {
|
|
101
|
+
const label = display(item.name);
|
|
102
|
+
const detail = description(item);
|
|
103
|
+
lines.push(`${index + 1}. ${item.value}${label && label !== item.value ? `(${label})` : ''}${item.value === current.currentValue ? t(' ✓ 当前') : ''}${detail ? ` — ${detail}` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
if (!current.options.length) lines.push(t('当前没有可用权限档位。'));
|
|
106
|
+
lines.push('', t('切换:/permission <序号或完整ID>'));
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(t('查看可用档位与序号:/permissionlist'));
|
|
109
|
+
}
|
|
110
|
+
lines.push(t(SCOPE));
|
|
111
|
+
return result(lines.join('\n'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let selected = requested;
|
|
115
|
+
const numericId = /^id:(\d+)$/iu.exec(requested);
|
|
116
|
+
if (numericId) selected = numericId[1];
|
|
117
|
+
else if (/^\d+$/u.test(requested)) {
|
|
118
|
+
const snapshot = SNAPSHOTS.load(state, key);
|
|
119
|
+
if (!snapshot || snapshot.sessionId !== sessionId) return result(t(REFRESH));
|
|
120
|
+
const index = Number(requested);
|
|
121
|
+
if (!Number.isSafeInteger(index) || index < 1 || !snapshot.ids[index - 1]) {
|
|
122
|
+
return result(t('权限档位序号无效,请重新执行 /permissionlist。'));
|
|
123
|
+
}
|
|
124
|
+
selected = snapshot.ids[index - 1];
|
|
125
|
+
}
|
|
126
|
+
if (selected === 'custom' || /^id:/iu.test(selected)) return result(t(USAGE));
|
|
127
|
+
if (typeof session?.executeCommand !== 'function') return result(t('当前 Host 暂不支持从机器人查看或切换权限档位。'));
|
|
128
|
+
signal.throwIfAborted();
|
|
129
|
+
writing = true;
|
|
130
|
+
const execution = await session.executeCommand(`/permission ${selected}`, { signal });
|
|
131
|
+
if (execution === undefined) return result(t('当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。'));
|
|
132
|
+
const outcome = execution?.result;
|
|
133
|
+
if (!outcome || !['success', 'error'].includes(outcome.kind)
|
|
134
|
+
|| (outcome.text !== undefined && typeof outcome.text !== 'string')) throw new TypeError('Invalid permission command result');
|
|
135
|
+
if (outcome.kind === 'error') {
|
|
136
|
+
if (outcome.text?.startsWith('unknown preset ')) {
|
|
137
|
+
return result(t('该权限档位不存在或已不可用,请重新执行 /permissionlist。'));
|
|
138
|
+
}
|
|
139
|
+
return result([t('权限切换失败。'), display(outcome.text)].filter(Boolean).join('\n'));
|
|
140
|
+
}
|
|
141
|
+
return result([
|
|
142
|
+
t('当前会话权限已切换为:{id}', { id: selected }), '', t(SCOPE),
|
|
143
|
+
...(selected === 'danger-full-access' ? [t('恢复工作区权限:/permission workspace-write(需 Host 支持该档位)')] : []),
|
|
144
|
+
].join('\n'));
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return result(errorMessage(error, writing));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCommandListSnapshots, COMMAND_LIST_TTL_MS, COMMAND_LIST_MAX_ENTRIES } from './command-list-snapshots.mjs';
|
|
1
2
|
import {
|
|
2
3
|
normalizeAgentPresetCatalog,
|
|
3
4
|
normalizeAgentPresetId,
|
|
@@ -19,9 +20,9 @@ const PRESET_USAGE = [
|
|
|
19
20
|
'/preset --default 跟随 Host 默认',
|
|
20
21
|
].join('\n');
|
|
21
22
|
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
|
22
|
-
const LIST_SNAPSHOTS =
|
|
23
|
-
export const PRESET_LIST_SNAPSHOT_TTL_MS =
|
|
24
|
-
export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES =
|
|
23
|
+
const LIST_SNAPSHOTS = createCommandListSnapshots();
|
|
24
|
+
export const PRESET_LIST_SNAPSHOT_TTL_MS = COMMAND_LIST_TTL_MS;
|
|
25
|
+
export const PRESET_LIST_SNAPSHOT_MAX_ENTRIES = COMMAND_LIST_MAX_ENTRIES;
|
|
25
26
|
|
|
26
27
|
function commandResult(message) {
|
|
27
28
|
return {
|
|
@@ -140,59 +141,13 @@ function formatUpdated(settings) {
|
|
|
140
141
|
].join('\n');
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
function stateSnapshots(state, { create = false } = {}) {
|
|
144
|
-
if ((typeof state !== 'object' || state === null) && typeof state !== 'function') return null;
|
|
145
|
-
let snapshots = LIST_SNAPSHOTS.get(state);
|
|
146
|
-
if (!snapshots && create) {
|
|
147
|
-
snapshots = new Map();
|
|
148
|
-
LIST_SNAPSHOTS.set(state, snapshots);
|
|
149
|
-
}
|
|
150
|
-
return snapshots ?? null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function pruneExpiredSnapshots(snapshots, now) {
|
|
154
|
-
for (const [snapshotKey, snapshot] of snapshots) {
|
|
155
|
-
if (snapshot.expiresAt <= now) snapshots.delete(snapshotKey);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function saveSnapshot(state, key, items) {
|
|
160
|
-
const snapshots = stateSnapshots(state, { create: true });
|
|
161
|
-
if (!snapshots) return;
|
|
162
|
-
const now = Date.now();
|
|
163
|
-
pruneExpiredSnapshots(snapshots, now);
|
|
164
|
-
snapshots.delete(key);
|
|
165
|
-
snapshots.set(key, {
|
|
166
|
-
expiresAt: now + PRESET_LIST_SNAPSHOT_TTL_MS,
|
|
167
|
-
ids: items.map((item) => item.id),
|
|
168
|
-
});
|
|
169
|
-
while (snapshots.size > PRESET_LIST_SNAPSHOT_MAX_ENTRIES) {
|
|
170
|
-
const oldest = snapshots.keys().next();
|
|
171
|
-
if (oldest.done) break;
|
|
172
|
-
snapshots.delete(oldest.value);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function loadSnapshot(state, key) {
|
|
177
|
-
const snapshots = stateSnapshots(state);
|
|
178
|
-
const snapshot = snapshots?.get(key);
|
|
179
|
-
if (!snapshots || !snapshot) return null;
|
|
180
|
-
if (snapshot.expiresAt <= Date.now()) {
|
|
181
|
-
snapshots.delete(key);
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
snapshots.delete(key);
|
|
185
|
-
snapshots.set(key, snapshot);
|
|
186
|
-
return snapshot.ids;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
144
|
function presetFromSnapshot(state, key, requested) {
|
|
190
145
|
if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
|
|
191
146
|
const index = Number(requested);
|
|
192
147
|
if (!Number.isSafeInteger(index) || index < 1) {
|
|
193
148
|
return { numeric: true, error: t('Agent Preset 序号无效,请先执行 /presetlist。') };
|
|
194
149
|
}
|
|
195
|
-
const snapshot =
|
|
150
|
+
const snapshot = LIST_SNAPSHOTS.load(state, key);
|
|
196
151
|
if (!snapshot) {
|
|
197
152
|
return { numeric: true, error: t('请先执行 /presetlist,再按列表序号选择 Agent Preset。') };
|
|
198
153
|
}
|
|
@@ -260,7 +215,7 @@ export async function runPresetCommand(text, harness, state, key, options = {})
|
|
|
260
215
|
if (!/^\/(?:presetlist|presets)[ \t]*$/iu.test(command)) return commandResult(t(PRESET_LIST_USAGE));
|
|
261
216
|
try {
|
|
262
217
|
const current = await settings(harness, requestOptions);
|
|
263
|
-
|
|
218
|
+
LIST_SNAPSHOTS.save(state, key, current.agentPresetCatalog.items.map((item) => item.id));
|
|
264
219
|
return commandResult(formatList(current));
|
|
265
220
|
} catch (error) {
|
|
266
221
|
return commandResult(presetErrorMessage(error, 'list'));
|
|
@@ -276,8 +276,8 @@ export class OutboundArtifactRegistry {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* Bind one channel
|
|
280
|
-
*
|
|
279
|
+
* Bind one channel delivery consumer to the Turn its request starts.
|
|
280
|
+
* Only this consumer can retain files for delivery after the Turn ends.
|
|
281
281
|
*/
|
|
282
282
|
openConsumer(sessionId, promptRpcId) {
|
|
283
283
|
if (typeof sessionId !== 'string' || !sessionId
|
|
@@ -310,8 +310,24 @@ export class OutboundArtifactRegistry {
|
|
|
310
310
|
};
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
313
|
+
/** The Host claims input before assembling the first step's prompt. */
|
|
314
|
+
observeClaimedMessage(session, message, turn) {
|
|
315
|
+
const sessionId = sessionIdOf(session);
|
|
316
|
+
if (!sessionId || !Number.isInteger(turn) || turn < 0
|
|
317
|
+
|| this.#currentTurn(session) !== turn) return;
|
|
318
|
+
this.#bindConsumer(sessionId, message?.source?.rpcId, turn);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#bindConsumer(sessionId, rpcId, turn) {
|
|
322
|
+
if (typeof rpcId !== 'string' || !rpcId) return;
|
|
323
|
+
const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
|
|
324
|
+
if (!consumer || consumer.released) return;
|
|
325
|
+
consumer.turn = turn;
|
|
326
|
+
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Observe durable Session events to bind delivery ownership and clean up. */
|
|
330
|
+
observeSessionEvent(session, event, { logger } = {}) {
|
|
315
331
|
const sessionId = sessionIdOf(session);
|
|
316
332
|
if (!sessionId || !event || typeof event !== 'object') return;
|
|
317
333
|
if (event.type === 'turn/start') {
|
|
@@ -321,21 +337,50 @@ export class OutboundArtifactRegistry {
|
|
|
321
337
|
}
|
|
322
338
|
if (event.type === 'user/message') {
|
|
323
339
|
const rpcId = event.data?.source?.rpcId;
|
|
324
|
-
const turn = this.#
|
|
340
|
+
const turn = this.#currentTurn(session);
|
|
325
341
|
if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
|
|
326
342
|
this.#openTurns.set(sessionId, turn);
|
|
327
|
-
|
|
328
|
-
if (!consumer || consumer.released) return;
|
|
329
|
-
consumer.turn = turn;
|
|
330
|
-
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
|
|
343
|
+
this.#bindConsumer(sessionId, rpcId, turn);
|
|
331
344
|
return;
|
|
332
345
|
}
|
|
333
346
|
if (event.type !== 'turn/end') return;
|
|
334
347
|
const turn = event.data?.turn;
|
|
335
348
|
if (!Number.isInteger(turn) || turn < 0) return;
|
|
336
|
-
|
|
349
|
+
// Retain the observed closed state so a stale Session snapshot cannot
|
|
350
|
+
// reopen a Turn while its consumer is still waiting to claim the files.
|
|
351
|
+
if (this.#currentTurn(session) === turn) this.#openTurns.set(sessionId, null);
|
|
352
|
+
if (!this.#consumerForTurn(sessionId, turn)) {
|
|
353
|
+
const keyForTurn = turnKey(sessionId, turn);
|
|
354
|
+
const artifactCount = (this.#turns.get(keyForTurn)?.size ?? 0)
|
|
355
|
+
+ (this.#stagedTurns.get(keyForTurn)?.size ?? 0);
|
|
356
|
+
this.discard(sessionId, turn);
|
|
357
|
+
if (artifactCount > 0) {
|
|
358
|
+
logger?.warn?.('[dsh-im] discarded files without an active IM delivery consumer:', {
|
|
359
|
+
sessionId, turn, artifactCount,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#currentTurn(session) {
|
|
366
|
+
const sessionId = sessionIdOf(session);
|
|
367
|
+
return this.#openTurns.has(sessionId)
|
|
368
|
+
? this.#openTurns.get(sessionId)
|
|
369
|
+
: currentTurn({ session });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#consumerForTurn(sessionId, turn) {
|
|
337
373
|
const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
|
|
338
|
-
|
|
374
|
+
return consumer && !consumer.released && consumer.turn === turn
|
|
375
|
+
&& this.#consumersByPrompt.get(promptKey(sessionId, consumer.promptRpcId)) === consumer
|
|
376
|
+
? consumer : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Prompt guidance follows the active Turn's actual delivery owner. */
|
|
380
|
+
hasActiveConsumer(session) {
|
|
381
|
+
const turn = this.#currentTurn(session);
|
|
382
|
+
return Number.isInteger(turn)
|
|
383
|
+
&& this.#consumerForTurn(sessionIdOf(session), turn) !== null;
|
|
339
384
|
}
|
|
340
385
|
|
|
341
386
|
/** A disposed Session cannot have another channel consumer claim its files. */
|
|
@@ -371,7 +416,7 @@ export class OutboundArtifactRegistry {
|
|
|
371
416
|
const agent = exec?.agent;
|
|
372
417
|
const sessionId = agent?.session?.header?.id;
|
|
373
418
|
const workspace = agent?.session?.header?.cwd;
|
|
374
|
-
const turn = this.#
|
|
419
|
+
const turn = this.#currentTurn(agent?.session);
|
|
375
420
|
if (typeof sessionId !== 'string' || !sessionId
|
|
376
421
|
|| typeof workspace !== 'string' || !workspace || turn === null) {
|
|
377
422
|
throw artifactError(
|
|
@@ -380,8 +425,25 @@ export class OutboundArtifactRegistry {
|
|
|
380
425
|
);
|
|
381
426
|
}
|
|
382
427
|
|
|
428
|
+
const consumer = this.#consumerForTurn(sessionId, turn);
|
|
429
|
+
const unavailable = () => artifactError(
|
|
430
|
+
'artifact-consumer-required',
|
|
431
|
+
'No active IM delivery consumer owns this turn. The file was not queued. Use the host present tool if available to return the file in this conversation.',
|
|
432
|
+
);
|
|
433
|
+
if (!consumer) throw unavailable();
|
|
434
|
+
|
|
383
435
|
const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
|
|
384
436
|
const { storagePath, ...snapshotMetadata } = snapshot;
|
|
437
|
+
// Copying and hashing yield to cancellation, disposal and Turn completion.
|
|
438
|
+
// Never leave a late snapshot behind after its delivery owner has gone.
|
|
439
|
+
try {
|
|
440
|
+
exec?.signal?.throwIfAborted();
|
|
441
|
+
if (this.#currentTurn(agent?.session) !== turn
|
|
442
|
+
|| this.#consumerForTurn(sessionId, turn) !== consumer) throw unavailable();
|
|
443
|
+
} catch (error) {
|
|
444
|
+
await unlink(storagePath).catch(() => undefined);
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
385
447
|
const artifact = Object.freeze({
|
|
386
448
|
kind: ARTIFACT_KIND,
|
|
387
449
|
schemaVersion: 1,
|
|
@@ -578,7 +640,7 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
|
|
|
578
640
|
};
|
|
579
641
|
const definition = Object.freeze({
|
|
580
642
|
name: OUTBOUND_ARTIFACT_TOOL,
|
|
581
|
-
description: 'Register a readable file or generated image for delivery through the current conversation after this turn. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
|
|
643
|
+
description: 'Register a readable file or generated image for delivery through the current IM conversation after this turn. Requires an active IM delivery consumer; in Web, Desktop, or CLI conversations use the host present tool if available. Existing and newly created files are both valid. Success means queued, not sent; do not claim the user has received the file.',
|
|
582
644
|
parameters: {
|
|
583
645
|
type: 'object',
|
|
584
646
|
additionalProperties: false,
|
|
@@ -643,20 +705,28 @@ export function createOutboundArtifactTool({ registry = outboundArtifactRegistry
|
|
|
643
705
|
return Object.freeze({ definition, onResult });
|
|
644
706
|
}
|
|
645
707
|
|
|
646
|
-
/** Register the file-return tool
|
|
708
|
+
/** Register the file-return tool with guidance scoped to an owned IM Turn. */
|
|
647
709
|
export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
|
|
648
710
|
if (typeof ctx?.tools?.register !== 'function'
|
|
649
|
-
|| typeof ctx?.systemPrompt?.
|
|
711
|
+
|| typeof ctx?.systemPrompt?.context !== 'function'
|
|
650
712
|
|| typeof ctx?.on !== 'function') return false;
|
|
713
|
+
const logger = typeof ctx.logger === 'function'
|
|
714
|
+
? ctx.logger('dsh-im:outbound-artifact')
|
|
715
|
+
: (ctx.logger ?? console);
|
|
651
716
|
const tool = createOutboundArtifactTool({ registry });
|
|
652
717
|
ctx.tools.register(tool.definition);
|
|
653
718
|
ctx.on('tools/result', tool.onResult);
|
|
654
|
-
ctx.on('
|
|
719
|
+
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
|
720
|
+
registry.observeClaimedMessage(agent?.session, message, turn);
|
|
721
|
+
}, { global: true });
|
|
722
|
+
ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event, { logger }));
|
|
655
723
|
ctx.on('session/disposed', (session) => registry.disposeSession(session));
|
|
656
|
-
ctx.systemPrompt.
|
|
724
|
+
ctx.systemPrompt.context({
|
|
657
725
|
name: 'dsh-im:return-file',
|
|
658
726
|
order: 115,
|
|
659
|
-
text:
|
|
727
|
+
text: (assembly) => registry.hasActiveConsumer(assembly?.agent?.session)
|
|
728
|
+
? `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery. This tool only registers the file; the channel uploads and sends it after your turn finishes. In your reply say the file is prepared or queued, never that it has already been sent or received. The channel reports delivery failures separately.`
|
|
729
|
+
: '',
|
|
660
730
|
});
|
|
661
731
|
return true;
|
|
662
732
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
2
|
-
import { randomInt } from 'node:crypto';
|
|
3
2
|
|
|
4
3
|
import { createEditableMessageStream } from '../shared/editable-message-stream.mjs';
|
|
5
4
|
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
|
@@ -357,6 +356,11 @@ function telegramMessageId(value) {
|
|
|
357
356
|
return Number.isSafeInteger(value?.message_id) ? String(value.message_id) : null;
|
|
358
357
|
}
|
|
359
358
|
|
|
359
|
+
function telegramMessageUnchanged(error) {
|
|
360
|
+
return error?.providerCode === 400
|
|
361
|
+
&& /^(?:Bad Request: )?message is not modified(?::|$)/iu.test(error?.message ?? '');
|
|
362
|
+
}
|
|
363
|
+
|
|
360
364
|
function telegramFailure(error) {
|
|
361
365
|
const providerCode = Number(error?.providerCode);
|
|
362
366
|
const status = Number(error?.status);
|
|
@@ -687,6 +691,9 @@ export class TelegramBotClient {
|
|
|
687
691
|
messageId: placeholderMessageId,
|
|
688
692
|
text: chunks[0],
|
|
689
693
|
signal: this.#signal,
|
|
694
|
+
}).catch((error) => {
|
|
695
|
+
// The final answer may already be visible from the last progress edit.
|
|
696
|
+
if (!telegramMessageUnchanged(error)) throw error;
|
|
690
697
|
});
|
|
691
698
|
firstUnsent = 1;
|
|
692
699
|
} catch (error) {
|
|
@@ -814,6 +821,8 @@ export class TelegramBotClient {
|
|
|
814
821
|
messageId,
|
|
815
822
|
richMessage: { markdown: chunks[0].markdown },
|
|
816
823
|
signal: this.#signal,
|
|
824
|
+
}).catch((error) => {
|
|
825
|
+
if (!telegramMessageUnchanged(error)) throw error;
|
|
817
826
|
});
|
|
818
827
|
} catch (error) {
|
|
819
828
|
const failure = telegramFailure(error);
|
|
@@ -869,31 +878,8 @@ export class TelegramBotClient {
|
|
|
869
878
|
}
|
|
870
879
|
|
|
871
880
|
async openDeliveryStream(target) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const updateDraft = async (block) => {
|
|
875
|
-
const richMessage = { markdown: toTelegramRichMarkdown(block.text) };
|
|
876
|
-
await this.#api.sendRichMessageDraft({
|
|
877
|
-
chatId: target.chatId,
|
|
878
|
-
draftId,
|
|
879
|
-
richMessage,
|
|
880
|
-
messageThreadId: target.messageThreadId,
|
|
881
|
-
signal: this.#signal,
|
|
882
|
-
});
|
|
883
|
-
return deliveryResult('telegram-rich-draft', []);
|
|
884
|
-
};
|
|
885
|
-
const stream = new TelegramDeliveryStream({
|
|
886
|
-
update: updateDraft,
|
|
887
|
-
finish: (block) => this.#sendRich(target, block),
|
|
888
|
-
fail: (block) => this.#sendPlain(target, block.text),
|
|
889
|
-
presentation: 'telegram-rich-draft',
|
|
890
|
-
keepalive: true,
|
|
891
|
-
logger: this.#logger,
|
|
892
|
-
});
|
|
893
|
-
await stream.update(createTextDeliveryBlock(t('正在处理…'), 'plain'));
|
|
894
|
-
return stream;
|
|
895
|
-
}
|
|
896
|
-
|
|
881
|
+
// Real messages keep the Android composer available for /stop and /steer;
|
|
882
|
+
// private-chat Rich Drafts can block sending for the whole turn (#276).
|
|
897
883
|
const placeholder = await this.#api.sendMessage({
|
|
898
884
|
chatId: target.chatId,
|
|
899
885
|
text: t('正在处理…'),
|