@xmanrui/dsh-im 4.32.0 → 4.33.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 +8 -6
- package/README.md +8 -6
- package/lib/client.js +1 -71
- package/lib/index.js +289 -289
- 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/session-sync-coordinator.mjs +10 -13
- package/src/channels/feishu/bridge.mjs +75 -7
- package/src/channels/feishu/feishu-cards.mjs +24 -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/shared/harness-client.mjs +5 -4
- package/src/channels/shared/i18n-en/feishu.mjs +7 -0
- package/src/channels/shared/semantic/artifact.mjs +88 -18
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { addAbortSignal, Readable } from 'node:stream';
|
|
2
|
+
import { downloadFeishuResourceRanges } from './resource-download.mjs';
|
|
1
3
|
import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
|
|
2
4
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
3
5
|
import { t } from '../shared/i18n.mjs';
|
|
4
6
|
|
|
5
7
|
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
|
|
8
|
+
const FEISHU_RESOURCE_SIZE_LIMIT_CODE = 234037;
|
|
6
9
|
const FEISHU_CARD_MESSAGE_CONTENT_TYPE = 'raw_card_content';
|
|
7
10
|
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
|
|
8
11
|
const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
|
|
@@ -305,31 +308,6 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
|
|
|
305
308
|
}
|
|
306
309
|
}
|
|
307
310
|
|
|
308
|
-
async function readStream(stream, { signal }) {
|
|
309
|
-
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
310
|
-
throw new Error('Feishu file download returned no readable stream');
|
|
311
|
-
}
|
|
312
|
-
signal?.throwIfAborted();
|
|
313
|
-
const abort = () => stream.destroy?.(
|
|
314
|
-
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
|
|
315
|
-
);
|
|
316
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
317
|
-
const chunks = [];
|
|
318
|
-
let size = 0;
|
|
319
|
-
try {
|
|
320
|
-
for await (const chunk of stream) {
|
|
321
|
-
signal?.throwIfAborted();
|
|
322
|
-
const data = Buffer.from(chunk);
|
|
323
|
-
size += data.length;
|
|
324
|
-
chunks.push(data);
|
|
325
|
-
}
|
|
326
|
-
signal?.throwIfAborted();
|
|
327
|
-
return Buffer.concat(chunks, size);
|
|
328
|
-
} finally {
|
|
329
|
-
signal?.removeEventListener('abort', abort);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
311
|
function providerCode(value) {
|
|
334
312
|
if (!value || typeof value !== 'object') return null;
|
|
335
313
|
const code = value.code ?? value.error?.code;
|
|
@@ -375,7 +353,7 @@ async function feishuProviderCode(error, signal) {
|
|
|
375
353
|
seen.add(value);
|
|
376
354
|
const directCode = providerCode(value);
|
|
377
355
|
const data = value.response?.data ?? value.data;
|
|
378
|
-
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
|
|
356
|
+
if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || directCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
|
|
379
357
|
data?.destroy?.();
|
|
380
358
|
return directCode;
|
|
381
359
|
}
|
|
@@ -383,21 +361,22 @@ async function feishuProviderCode(error, signal) {
|
|
|
383
361
|
const body = await readFeishuErrorBody(data, signal);
|
|
384
362
|
try {
|
|
385
363
|
const parsedCode = providerCode(JSON.parse(body));
|
|
386
|
-
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
|
|
364
|
+
if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || parsedCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return parsedCode;
|
|
387
365
|
} catch {
|
|
388
366
|
// Non-JSON provider failures keep the generic image download message.
|
|
389
367
|
}
|
|
390
368
|
} else {
|
|
391
369
|
const dataCode = providerCode(data);
|
|
392
|
-
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
|
|
370
|
+
if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE || dataCode === FEISHU_RESOURCE_SIZE_LIMIT_CODE) return dataCode;
|
|
393
371
|
}
|
|
394
372
|
pending.push(value.cause);
|
|
395
373
|
}
|
|
396
374
|
return null;
|
|
397
375
|
}
|
|
398
376
|
|
|
399
|
-
async function feishuImageDownloadError(error, signal) {
|
|
400
|
-
if (await feishuProviderCode(error, signal)
|
|
377
|
+
async function feishuImageDownloadError(error, signal, code = undefined) {
|
|
378
|
+
if (code === undefined) code = await feishuProviderCode(error, signal);
|
|
379
|
+
if (code !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
|
|
401
380
|
return new ImagePromptError(
|
|
402
381
|
'feishu-image-permission-required',
|
|
403
382
|
'Feishu image download requires the im:message:readonly tenant scope',
|
|
@@ -406,33 +385,59 @@ async function feishuImageDownloadError(error, signal) {
|
|
|
406
385
|
);
|
|
407
386
|
}
|
|
408
387
|
|
|
409
|
-
function
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
388
|
+
async function feishuResourceStream(event, client, key, type, { signal, maxBytes } = {}) {
|
|
389
|
+
signal?.throwIfAborted();
|
|
390
|
+
const path = { message_id: event.message.message_id, file_key: key };
|
|
391
|
+
let resource;
|
|
392
|
+
try {
|
|
393
|
+
resource = await client?.im?.v1?.messageResource?.get?.({ path, params: { type } });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (signal?.aborted) {
|
|
396
|
+
error?.response?.data?.destroy?.();
|
|
397
|
+
signal.throwIfAborted();
|
|
398
|
+
}
|
|
399
|
+
const code = await feishuProviderCode(error, signal);
|
|
400
|
+
if (code !== FEISHU_RESOURCE_SIZE_LIMIT_CODE) {
|
|
401
|
+
throw type === 'image' ? await feishuImageDownloadError(error, signal, code) : error;
|
|
402
|
+
}
|
|
403
|
+
return Readable.from((async function* ranges() {
|
|
414
404
|
try {
|
|
415
|
-
|
|
416
|
-
path: {
|
|
417
|
-
message_id: event.message.message_id,
|
|
418
|
-
file_key: key,
|
|
419
|
-
},
|
|
420
|
-
params: { type: 'image' },
|
|
421
|
-
});
|
|
405
|
+
yield* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes });
|
|
422
406
|
} catch (error) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
resource?.getReadableStream?.().destroy?.();
|
|
429
|
-
throw new ImagePromptError(
|
|
430
|
-
'image-too-large',
|
|
431
|
-
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
432
|
-
imageDownloadLimitMessage(maxBytes),
|
|
433
|
-
);
|
|
407
|
+
try {
|
|
408
|
+
throw type === 'image' ? await feishuImageDownloadError(error, signal) : error;
|
|
409
|
+
} finally {
|
|
410
|
+
error?.response?.data?.destroy?.();
|
|
411
|
+
}
|
|
434
412
|
}
|
|
435
|
-
|
|
413
|
+
}()), { objectMode: false, signal });
|
|
414
|
+
}
|
|
415
|
+
const stream = resource?.getReadableStream?.();
|
|
416
|
+
try {
|
|
417
|
+
signal?.throwIfAborted();
|
|
418
|
+
const size = declaredSize(resource?.headers);
|
|
419
|
+
if (size !== null && size > maxBytes) {
|
|
420
|
+
throw new ImagePromptError(
|
|
421
|
+
'image-too-large',
|
|
422
|
+
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
|
|
423
|
+
imageDownloadLimitMessage(maxBytes),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
427
|
+
throw new Error(`Feishu ${type} download returned no readable stream`);
|
|
428
|
+
}
|
|
429
|
+
return signal ? addAbortSignal(signal, stream) : stream;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
stream?.destroy?.();
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function feishuImageSource(event, client, key) {
|
|
437
|
+
return {
|
|
438
|
+
async load({ signal, maxBytes }) {
|
|
439
|
+
const stream = await feishuResourceStream(event, client, key, 'image', { signal, maxBytes });
|
|
440
|
+
return readBoundedStream(stream, { signal, maxBytes });
|
|
436
441
|
},
|
|
437
442
|
};
|
|
438
443
|
}
|
|
@@ -443,16 +448,8 @@ function feishuFileSource(event, client, file) {
|
|
|
443
448
|
return {
|
|
444
449
|
name: nonEmptyString(file?.file_name) ?? 'file',
|
|
445
450
|
async load({ signal } = {}) {
|
|
446
|
-
signal
|
|
447
|
-
|
|
448
|
-
path: {
|
|
449
|
-
message_id: event.message.message_id,
|
|
450
|
-
file_key: key,
|
|
451
|
-
},
|
|
452
|
-
params: { type: 'file' },
|
|
453
|
-
});
|
|
454
|
-
signal?.throwIfAborted();
|
|
455
|
-
return readStream(resource?.getReadableStream?.(), { signal });
|
|
451
|
+
const stream = await feishuResourceStream(event, client, key, 'file', { signal });
|
|
452
|
+
return { stream };
|
|
456
453
|
},
|
|
457
454
|
};
|
|
458
455
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { atConnectionStage, createConnectionDiagnostics } from '../shared/connection-error.mjs';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { connectionTestMessage } from '../shared/connection-test.mjs';
|
|
4
4
|
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
5
5
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
@@ -563,10 +563,60 @@ export class MultiBotDshFeishuController {
|
|
|
563
563
|
});
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
async #deliveryAccount(config) {
|
|
567
|
+
let verified;
|
|
568
|
+
try {
|
|
569
|
+
const resolved = await this.#credentials.resolve(config.secretRef);
|
|
570
|
+
if (!resolved?.value) throw new Error('Account credentials unavailable');
|
|
571
|
+
verified = await this.#verifyApp({ appId: config.appId, appSecret: resolved.value, domain: config.domain });
|
|
572
|
+
} catch (cause) {
|
|
573
|
+
const error = new Error('Account credentials could not be verified', { cause });
|
|
574
|
+
error.code = 'account-unverified';
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
if (!verified?.openId || verified.openId !== config.botOpenId) {
|
|
578
|
+
const error = new Error('Authenticated account identity changed');
|
|
579
|
+
error.code = 'account-changed';
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
582
|
+
const fingerprint = createHash('sha256').update(JSON.stringify({
|
|
583
|
+
provider: 'feishu', domain: config.domain, appId: config.appId, botOpenId: verified.openId,
|
|
584
|
+
})).digest('hex');
|
|
585
|
+
return { fingerprint, ...(verified.name ? { name: verified.name } : {}) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async describeDeliveryAccount(botId) {
|
|
589
|
+
this.#assertOpen('capability-unavailable');
|
|
590
|
+
return this.#withBotTransition(botId, async () => {
|
|
591
|
+
this.#assertOpen('capability-unavailable');
|
|
592
|
+
const config = this.#requireBot(botId);
|
|
593
|
+
const account = await this.#deliveryAccount(config);
|
|
594
|
+
this.#assertOpen('capability-unavailable');
|
|
595
|
+
return { version: 1, botId, channel: 'feishu', account,
|
|
596
|
+
connected: isConnected(connectionStatus(this.#runtimes.get(botId))),
|
|
597
|
+
capabilities: ['proactive-text-checked'] };
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
566
601
|
async sendProactiveText(botId, target, text, options = {}) {
|
|
567
|
-
|
|
602
|
+
const checked = options.expectedFingerprint !== undefined;
|
|
603
|
+
this.#assertOpen(checked ? 'capability-unavailable' : undefined);
|
|
568
604
|
return this.#withBotTransition(botId, async () => {
|
|
569
|
-
this.#
|
|
605
|
+
this.#assertOpen(checked ? 'capability-unavailable' : undefined);
|
|
606
|
+
const config = this.#requireBot(botId);
|
|
607
|
+
if (checked) {
|
|
608
|
+
const account = await this.#deliveryAccount(config);
|
|
609
|
+
if (account.fingerprint !== options.expectedFingerprint) {
|
|
610
|
+
const error = new Error('Authenticated account identity changed');
|
|
611
|
+
error.code = 'account-changed';
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
// Account verification yields; a Registration can be replaced or disposed
|
|
615
|
+
// before the SDK request starts. Recheck its owner at the effect boundary.
|
|
616
|
+
options.beforeSend?.();
|
|
617
|
+
this.#assertOpen('capability-unavailable');
|
|
618
|
+
options.signal?.throwIfAborted();
|
|
619
|
+
}
|
|
570
620
|
const runtime = this.#runtimes.get(botId);
|
|
571
621
|
if (!isConnected(connectionStatus(runtime))
|
|
572
622
|
|| typeof runtime.sendProactiveText !== 'function') {
|
|
@@ -1386,8 +1436,12 @@ export class MultiBotDshFeishuController {
|
|
|
1386
1436
|
return config;
|
|
1387
1437
|
}
|
|
1388
1438
|
|
|
1389
|
-
#assertOpen() {
|
|
1390
|
-
if (this.#closed)
|
|
1439
|
+
#assertOpen(code) {
|
|
1440
|
+
if (this.#closed) {
|
|
1441
|
+
const error = new Error('The Feishu controller is closed');
|
|
1442
|
+
if (code) error.code = code;
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1391
1445
|
}
|
|
1392
1446
|
|
|
1393
1447
|
#serializeConfig(operation) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ImagePromptError, imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
|
|
2
|
+
|
|
3
|
+
const RESOURCE_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function headerValue(headers, name) {
|
|
6
|
+
if (typeof headers?.get === 'function') return headers.get(name) ?? null;
|
|
7
|
+
const key = Object.keys(headers ?? {}).find((key) => key.toLowerCase() === name);
|
|
8
|
+
return key ? headers[key] : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function checkLimit(size, maxBytes) {
|
|
12
|
+
if (size > maxBytes) {
|
|
13
|
+
throw new ImagePromptError(
|
|
14
|
+
'image-too-large',
|
|
15
|
+
`Feishu image exceeds ${maxBytes} bytes`,
|
|
16
|
+
imageDownloadLimitMessage(maxBytes),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function contentLength(headers) {
|
|
22
|
+
const value = headerValue(headers, 'content-length');
|
|
23
|
+
if (value === null || value === undefined) return null;
|
|
24
|
+
if (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
|
|
25
|
+
throw new Error('Invalid Feishu resource Content-Length');
|
|
26
|
+
}
|
|
27
|
+
return Number(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contentRange(headers, start, end, total) {
|
|
31
|
+
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(headerValue(headers, 'content-range') ?? '');
|
|
32
|
+
if (!match) throw new Error('Invalid Feishu resource Content-Range');
|
|
33
|
+
const [first, last, size] = match.slice(1).map(Number);
|
|
34
|
+
if (![first, last, size].every(Number.isSafeInteger)
|
|
35
|
+
|| first !== start || last < first || last > end || last >= size
|
|
36
|
+
|| (total !== null && size !== total)) {
|
|
37
|
+
throw new Error('Inconsistent Feishu resource Content-Range');
|
|
38
|
+
}
|
|
39
|
+
return { size, length: last - first + 1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stream sequential ranges; never buffer an ordinary file or publish a partial success. */
|
|
43
|
+
export async function* downloadFeishuResourceRanges(client, path, type, { signal, maxBytes } = {}) {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
let total = null;
|
|
46
|
+
do {
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
const end = Math.min(
|
|
49
|
+
offset + RESOURCE_CHUNK_BYTES - 1,
|
|
50
|
+
(total ?? maxBytes ?? Number.MAX_SAFE_INTEGER) - 1,
|
|
51
|
+
);
|
|
52
|
+
let status;
|
|
53
|
+
const response = await client.request({
|
|
54
|
+
method: 'GET',
|
|
55
|
+
url: `open-apis/im/v1/messages/${encodeURIComponent(path.message_id)}/resources/${encodeURIComponent(path.file_key)}`,
|
|
56
|
+
params: { type },
|
|
57
|
+
headers: { Range: `bytes=${offset}-${end}`, 'Accept-Encoding': 'identity' },
|
|
58
|
+
responseType: 'stream',
|
|
59
|
+
$return_headers: true,
|
|
60
|
+
decompress: false,
|
|
61
|
+
signal,
|
|
62
|
+
// The SDK strips status from successful responses, including stream responses.
|
|
63
|
+
validateStatus(value) {
|
|
64
|
+
status = value;
|
|
65
|
+
return value >= 200 && value < 300;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const stream = response?.data;
|
|
69
|
+
const abort = () => stream?.destroy?.(signal.reason);
|
|
70
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
71
|
+
try {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
|
|
74
|
+
throw new Error('Feishu resource download returned no readable stream');
|
|
75
|
+
}
|
|
76
|
+
const encoding = headerValue(response.headers, 'content-encoding');
|
|
77
|
+
if (encoding && encoding !== 'identity') {
|
|
78
|
+
throw new Error('Unexpected Feishu resource Content-Encoding');
|
|
79
|
+
}
|
|
80
|
+
let length = contentLength(response.headers);
|
|
81
|
+
if (status === 206) {
|
|
82
|
+
const range = contentRange(response.headers, offset, end, total);
|
|
83
|
+
total = range.size;
|
|
84
|
+
checkLimit(total, maxBytes);
|
|
85
|
+
if (length !== null && length !== range.length) {
|
|
86
|
+
throw new Error('Feishu resource Content-Length disagrees with Content-Range');
|
|
87
|
+
}
|
|
88
|
+
length = range.length;
|
|
89
|
+
} else if (status === 200 && offset === 0) {
|
|
90
|
+
// A server may ignore the first Range. Read it once as a whole file.
|
|
91
|
+
if (headerValue(response.headers, 'content-range') !== null) {
|
|
92
|
+
throw new Error('Unexpected Feishu resource Content-Range on HTTP 200');
|
|
93
|
+
}
|
|
94
|
+
checkLimit(length, maxBytes);
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Unexpected Feishu resource range status: ${status}`);
|
|
97
|
+
}
|
|
98
|
+
let received = 0;
|
|
99
|
+
for await (const chunk of stream) {
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
102
|
+
received += data.length;
|
|
103
|
+
checkLimit(offset + received, maxBytes);
|
|
104
|
+
if (length !== null && received > length) {
|
|
105
|
+
throw new Error('Feishu resource body exceeds the declared length');
|
|
106
|
+
}
|
|
107
|
+
yield data;
|
|
108
|
+
}
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
if (length !== null && received !== length) {
|
|
111
|
+
throw new Error('Feishu resource body does not match the declared length');
|
|
112
|
+
}
|
|
113
|
+
if (status === 200) return;
|
|
114
|
+
offset += received;
|
|
115
|
+
} finally {
|
|
116
|
+
signal?.removeEventListener('abort', abort);
|
|
117
|
+
stream?.destroy?.();
|
|
118
|
+
}
|
|
119
|
+
} while (offset < total);
|
|
120
|
+
}
|
|
@@ -1729,9 +1729,10 @@ export class HarnessClient {
|
|
|
1729
1729
|
this.#registerInteractionOwnership(sessionId, ownership);
|
|
1730
1730
|
this.#registerControlOwnership(ownership);
|
|
1731
1731
|
}
|
|
1732
|
-
//
|
|
1733
|
-
|
|
1734
|
-
|
|
1732
|
+
// Advertise delivery only when this request can actually claim its files.
|
|
1733
|
+
const closeArtifactConsumer = typeof onArtifact === 'function'
|
|
1734
|
+
? outboundArtifactRegistry.openConsumer(sessionId, promptRpcId)
|
|
1735
|
+
: null;
|
|
1735
1736
|
|
|
1736
1737
|
try {
|
|
1737
1738
|
const basePrompt = prompt;
|
|
@@ -1929,7 +1930,7 @@ export class HarnessClient {
|
|
|
1929
1930
|
}
|
|
1930
1931
|
}
|
|
1931
1932
|
}
|
|
1932
|
-
closeArtifactConsumer();
|
|
1933
|
+
closeArtifactConsumer?.();
|
|
1933
1934
|
if (ownership) {
|
|
1934
1935
|
this.#unregisterControlOwnership(ownership);
|
|
1935
1936
|
this.#unregisterInteractionOwnership(sessionId, ownership);
|
|
@@ -143,6 +143,8 @@ export default {
|
|
|
143
143
|
'请输入补充指令后再提交。': 'Enter an instruction before submitting.',
|
|
144
144
|
'操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
|
|
145
145
|
'卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
|
|
146
|
+
'新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。':
|
|
147
|
+
'Could not update the new-session card status. Send /m to reopen the menu.',
|
|
146
148
|
'请先选择至少一个会话。': 'Select at least one session first.',
|
|
147
149
|
'已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
|
|
148
150
|
'已批量关注 {count} 个会话,另有 {failed} 个未成功。':
|
|
@@ -187,6 +189,11 @@ export default {
|
|
|
187
189
|
'🤖 切换预设': '🤖 Switch preset',
|
|
188
190
|
'🧠 切换模型': '🧠 Switch model',
|
|
189
191
|
'🆕 新会话': '🆕 New session',
|
|
192
|
+
'正在开启…': 'Starting…',
|
|
193
|
+
'已执行:新会话': 'Done: new session',
|
|
194
|
+
'正在开启新会话…': 'Starting a new session…',
|
|
195
|
+
'已执行:新会话。发送消息即可开始。': 'Done: new session. Send a message to start.',
|
|
196
|
+
'新会话操作失败:{message}': 'New session action failed: {message}',
|
|
190
197
|
'📋 会话/关注': '📋 Sessions / watches',
|
|
191
198
|
'🗂 工作区列表': '🗂 Workspace list',
|
|
192
199
|
'**任务控制**': '**Task controls**',
|
|
@@ -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
|
}
|