@xmanrui/dsh-im 1.0.2 → 1.2.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/README.en.md +23 -3
- package/README.md +23 -3
- package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
- package/assets/logo_cn.png +0 -0
- package/lib/client.js +815 -560
- package/lib/index.js +163 -163
- package/package.json +1 -1
- package/plugin-src/client/agent-preset.js +15 -6
- package/plugin-src/client/channel-card-meta.js +48 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -19
- package/plugin-src/client/channels/dingtalk/styles.js +0 -6
- package/plugin-src/client/channels/feishu/index.js +41 -35
- package/plugin-src/client/channels/feishu/styles.js +0 -5
- package/plugin-src/client/channels/qq/index.js +24 -16
- package/plugin-src/client/channels/shared/token-channel.js +32 -24
- package/plugin-src/client/channels/wecom/index.js +24 -16
- package/plugin-src/client/channels/weixin/index.js +29 -23
- package/plugin-src/client/channels/weixin/styles.js +0 -5
- package/plugin-src/client/channels/whatsapp/api.js +11 -0
- package/plugin-src/client/channels/whatsapp/index.js +152 -23
- package/plugin-src/client/channels/whatsapp/styles.js +25 -0
- package/plugin-src/client/i18n.js +20 -0
- package/plugin-src/client/styles.js +23 -8
- package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
- package/plugin-src/host/index.mjs +14 -1
- package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
- package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
- package/src/channels/discord/discord-api.mjs +134 -6
- package/src/channels/discord/discord-runtime.mjs +15 -4
- package/src/channels/feishu/bridge.mjs +223 -15
- package/src/channels/feishu/feishu-channel.mjs +227 -1
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +217 -10
- package/src/channels/shared/editable-message-stream.mjs +18 -1
- package/src/channels/shared/harness-client.mjs +99 -7
- package/src/channels/shared/semantic/artifact.mjs +748 -0
- package/src/channels/shared/semantic/delivery.mjs +153 -0
- package/src/channels/shared/text-harness-bridge.mjs +149 -3
- package/src/channels/shared/workspace-session.mjs +15 -1
- package/src/channels/slack/manifest.mjs +1 -0
- package/src/channels/slack/slack-api.mjs +167 -4
- package/src/channels/slack/slack-runtime.mjs +21 -5
- package/src/channels/telegram/telegram-api.mjs +111 -5
- package/src/channels/telegram/telegram-runtime.mjs +18 -4
- package/src/channels/wecom/wecom-bridge.mjs +260 -12
- package/src/channels/weixin/weixin-api.mjs +268 -2
- package/src/channels/weixin/weixin-bridge.mjs +134 -3
- package/src/channels/weixin/weixin-controller.mjs +5 -1
- package/src/channels/weixin/weixin-runtime.mjs +5 -1
- package/src/channels/whatsapp/config-store.mjs +43 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
- package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { copyFile, lstat, mkdtemp, open, realpath, unlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, extname, isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const OUTBOUND_ARTIFACT_TOOL = 'dsh_im_return_file';
|
|
8
|
+
|
|
9
|
+
const ARTIFACT_KIND = 'dsh-im-outbound-artifact';
|
|
10
|
+
const ARTIFACT_READ_CHUNK_BYTES = 64 * 1024;
|
|
11
|
+
const MIME_BY_EXTENSION = new Map([
|
|
12
|
+
['.csv', 'text/csv'],
|
|
13
|
+
['.doc', 'application/msword'],
|
|
14
|
+
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
15
|
+
['.gif', 'image/gif'],
|
|
16
|
+
['.html', 'text/html'],
|
|
17
|
+
['.jpeg', 'image/jpeg'],
|
|
18
|
+
['.jpg', 'image/jpeg'],
|
|
19
|
+
['.json', 'application/json'],
|
|
20
|
+
['.md', 'text/markdown'],
|
|
21
|
+
['.pdf', 'application/pdf'],
|
|
22
|
+
['.png', 'image/png'],
|
|
23
|
+
['.rar', 'application/vnd.rar'],
|
|
24
|
+
['.txt', 'text/plain'],
|
|
25
|
+
['.webp', 'image/webp'],
|
|
26
|
+
['.xls', 'application/vnd.ms-excel'],
|
|
27
|
+
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
28
|
+
['.xml', 'application/xml'],
|
|
29
|
+
['.zip', 'application/zip'],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const artifactStorage = new WeakMap();
|
|
33
|
+
const materializedArtifactSources = new WeakMap();
|
|
34
|
+
const artifactProviderSettlements = new WeakMap();
|
|
35
|
+
let managedSnapshotDirectoryPromise;
|
|
36
|
+
|
|
37
|
+
function managedSnapshotDirectory() {
|
|
38
|
+
managedSnapshotDirectoryPromise ??= mkdtemp(join(tmpdir(), 'dsh-im-outbound-'));
|
|
39
|
+
return managedSnapshotDirectoryPromise;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function artifactError(code, message) {
|
|
43
|
+
const error = new Error(message);
|
|
44
|
+
error.code = code;
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function currentTurn(agent) {
|
|
49
|
+
const events = agent?.session?.events;
|
|
50
|
+
if (!Array.isArray(events)) return null;
|
|
51
|
+
let turn = null;
|
|
52
|
+
for (const event of events) {
|
|
53
|
+
if (event?.type === 'turn/start') {
|
|
54
|
+
turn = Number.isInteger(event.data?.turn) ? event.data.turn : null;
|
|
55
|
+
} else if (event?.type === 'turn/end' && event.data?.turn === turn) {
|
|
56
|
+
turn = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return Number.isInteger(turn) && turn >= 0 ? turn : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sessionIdOf(session) {
|
|
63
|
+
const sessionId = session?.id ?? session?.header?.id;
|
|
64
|
+
return typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function turnKey(sessionId, turn) {
|
|
68
|
+
return `${sessionId}\u0000${turn}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function promptKey(sessionId, promptRpcId) {
|
|
72
|
+
return `${sessionId}\u0000${promptRpcId}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeFileName(value) {
|
|
76
|
+
const cleaned = String(value ?? '')
|
|
77
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
78
|
+
.replace(/\p{Cf}/gu, '')
|
|
79
|
+
.replace(/\s+/g, ' ')
|
|
80
|
+
.trim();
|
|
81
|
+
if (!cleaned) return 'result.bin';
|
|
82
|
+
return [...cleaned].slice(0, 255).join('');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mediaTypeFor(name) {
|
|
86
|
+
return MIME_BY_EXTENSION.get(extname(name).toLowerCase()) ?? 'application/octet-stream';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sha256(bytes) {
|
|
90
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sameIdentity(left, right) {
|
|
94
|
+
return typeof left?.dev === 'bigint'
|
|
95
|
+
&& typeof left?.ino === 'bigint'
|
|
96
|
+
&& typeof left?.size === 'bigint'
|
|
97
|
+
&& typeof left?.mtimeNs === 'bigint'
|
|
98
|
+
&& typeof left?.ctimeNs === 'bigint'
|
|
99
|
+
&& left.dev === right?.dev
|
|
100
|
+
&& left.ino === right?.ino
|
|
101
|
+
&& left.size === right?.size
|
|
102
|
+
&& left.mtimeNs === right?.mtimeNs
|
|
103
|
+
&& left.ctimeNs === right?.ctimeNs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function hashFile(path, signal) {
|
|
107
|
+
let handle;
|
|
108
|
+
try {
|
|
109
|
+
handle = await open(path, fsConstants.O_RDONLY);
|
|
110
|
+
const hash = createHash('sha256');
|
|
111
|
+
const stream = handle.createReadStream({
|
|
112
|
+
autoClose: false,
|
|
113
|
+
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
|
114
|
+
...(signal ? { signal } : {}),
|
|
115
|
+
});
|
|
116
|
+
for await (const chunk of stream) {
|
|
117
|
+
signal?.throwIfAborted();
|
|
118
|
+
hash.update(chunk);
|
|
119
|
+
}
|
|
120
|
+
return hash.digest('hex');
|
|
121
|
+
} finally {
|
|
122
|
+
await handle?.close().catch(() => undefined);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read exactly the observed file size and prove EOF. No project-level size or time limit applies. */
|
|
127
|
+
export async function readExactArtifactFile(handle, expectedSize, {
|
|
128
|
+
signal,
|
|
129
|
+
errorCode = 'artifact-changed',
|
|
130
|
+
errorMessage = 'The result file changed while it was being read.',
|
|
131
|
+
} = {}) {
|
|
132
|
+
const changed = () => artifactError(errorCode, errorMessage);
|
|
133
|
+
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw changed();
|
|
134
|
+
signal?.throwIfAborted();
|
|
135
|
+
|
|
136
|
+
let bytes;
|
|
137
|
+
try {
|
|
138
|
+
bytes = Buffer.allocUnsafe(expectedSize);
|
|
139
|
+
} catch {
|
|
140
|
+
throw changed();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof handle?.createReadStream === 'function') {
|
|
144
|
+
const stream = handle.createReadStream({
|
|
145
|
+
autoClose: false,
|
|
146
|
+
start: 0,
|
|
147
|
+
// Node's end offset is inclusive, so this reads one byte beyond the
|
|
148
|
+
// observed size when the file grows and catches the change.
|
|
149
|
+
end: expectedSize,
|
|
150
|
+
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
|
|
151
|
+
...(signal ? { signal } : {}),
|
|
152
|
+
});
|
|
153
|
+
let offset = 0;
|
|
154
|
+
for await (const chunk of stream) {
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
157
|
+
if (offset + part.length > expectedSize) throw changed();
|
|
158
|
+
part.copy(bytes, offset);
|
|
159
|
+
offset += part.length;
|
|
160
|
+
}
|
|
161
|
+
if (offset !== expectedSize) throw changed();
|
|
162
|
+
return bytes;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let offset = 0;
|
|
166
|
+
while (offset < expectedSize) {
|
|
167
|
+
signal?.throwIfAborted();
|
|
168
|
+
const length = Math.min(ARTIFACT_READ_CHUNK_BYTES, expectedSize - offset);
|
|
169
|
+
const result = await handle.read(bytes, offset, length, offset);
|
|
170
|
+
const bytesRead = Number(result?.bytesRead ?? 0);
|
|
171
|
+
if (!Number.isInteger(bytesRead) || bytesRead <= 0 || bytesRead > length) throw changed();
|
|
172
|
+
offset += bytesRead;
|
|
173
|
+
}
|
|
174
|
+
signal?.throwIfAborted();
|
|
175
|
+
const probe = Buffer.allocUnsafe(1);
|
|
176
|
+
if ((await handle.read(probe, 0, 1, expectedSize))?.bytesRead !== 0) throw changed();
|
|
177
|
+
return bytes;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function snapshotFile(workspace, requestedPath, signal) {
|
|
181
|
+
signal?.throwIfAborted();
|
|
182
|
+
let storagePath;
|
|
183
|
+
try {
|
|
184
|
+
const candidate = isAbsolute(requestedPath)
|
|
185
|
+
? requestedPath
|
|
186
|
+
: resolve(workspace, requestedPath);
|
|
187
|
+
const canonicalPath = await realpath(candidate);
|
|
188
|
+
const source = await lstat(canonicalPath, { bigint: true });
|
|
189
|
+
if (!source.isFile()) {
|
|
190
|
+
throw artifactError('artifact-not-file', 'The requested path is not a file.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const directory = await managedSnapshotDirectory();
|
|
194
|
+
storagePath = join(directory, `${randomUUID()}.artifact`);
|
|
195
|
+
await copyFile(canonicalPath, storagePath, fsConstants.COPYFILE_EXCL);
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
|
+
|
|
198
|
+
const snapshot = await lstat(storagePath, { bigint: true });
|
|
199
|
+
const size = Number(snapshot.size);
|
|
200
|
+
if (!snapshot.isFile() || !Number.isSafeInteger(size) || size < 0) {
|
|
201
|
+
throw artifactError('artifact-unavailable', 'The file could not be prepared for delivery.');
|
|
202
|
+
}
|
|
203
|
+
const fileName = safeFileName(basename(candidate));
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
fileName,
|
|
206
|
+
mediaType: mediaTypeFor(fileName),
|
|
207
|
+
size,
|
|
208
|
+
digest: await hashFile(storagePath, signal),
|
|
209
|
+
storagePath,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (storagePath) await unlink(storagePath).catch(() => undefined);
|
|
213
|
+
if (error?.code?.startsWith?.('artifact-')) throw error;
|
|
214
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
215
|
+
throw artifactError('artifact-unavailable', 'The requested file is unavailable.');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function publicArtifact(artifact) {
|
|
220
|
+
return Object.freeze({
|
|
221
|
+
artifactId: artifact.artifactId,
|
|
222
|
+
fileName: artifact.fileName,
|
|
223
|
+
size: artifact.size,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function artifactKey(artifact) {
|
|
228
|
+
return artifact.artifactId;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function cleanupArtifactStorage(artifact) {
|
|
232
|
+
const storage = artifactStorage.get(artifact);
|
|
233
|
+
if (!storage) return;
|
|
234
|
+
storage.releaseRequested = true;
|
|
235
|
+
if (storage.materializing > 0) {
|
|
236
|
+
storage.cleanupRequested = true;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const pending = [...artifactProviderSettlements.get(artifact) ?? []];
|
|
240
|
+
if (pending.length > 0) {
|
|
241
|
+
if (!storage.cleanupDeferred) {
|
|
242
|
+
storage.cleanupDeferred = true;
|
|
243
|
+
void Promise.allSettled(pending).finally(() => {
|
|
244
|
+
storage.cleanupDeferred = false;
|
|
245
|
+
cleanupArtifactStorage(artifact);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
artifactStorage.delete(artifact);
|
|
251
|
+
artifactProviderSettlements.delete(artifact);
|
|
252
|
+
storage.onCleanup?.();
|
|
253
|
+
void unlink(storage.path).catch(() => undefined);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Holds successful tool results until the channel that owns the Session Turn
|
|
258
|
+
* collects them. It does not decide which files a user may send.
|
|
259
|
+
*/
|
|
260
|
+
export class OutboundArtifactRegistry {
|
|
261
|
+
#turns = new Map();
|
|
262
|
+
#stagedTurns = new Map();
|
|
263
|
+
#claimedTurns = new Map();
|
|
264
|
+
#claimSignals = new Map();
|
|
265
|
+
#signalClaims = new Map();
|
|
266
|
+
#consumersByPrompt = new Map();
|
|
267
|
+
#consumersByTurn = new Map();
|
|
268
|
+
#openTurns = new Map();
|
|
269
|
+
#uuid;
|
|
270
|
+
|
|
271
|
+
constructor({ uuid = randomUUID } = {}) {
|
|
272
|
+
if (typeof uuid !== 'function') throw new TypeError('uuid must be a function');
|
|
273
|
+
this.#uuid = uuid;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Bind one channel request to the Turn it starts. This owns cleanup only:
|
|
278
|
+
* it never changes tool visibility or decides whether a file may be sent.
|
|
279
|
+
*/
|
|
280
|
+
openConsumer(sessionId, promptRpcId) {
|
|
281
|
+
if (typeof sessionId !== 'string' || !sessionId
|
|
282
|
+
|| typeof promptRpcId !== 'string' || !promptRpcId) {
|
|
283
|
+
throw new TypeError('sessionId and promptRpcId are required');
|
|
284
|
+
}
|
|
285
|
+
const key = promptKey(sessionId, promptRpcId);
|
|
286
|
+
const consumer = {
|
|
287
|
+
sessionId,
|
|
288
|
+
promptRpcId,
|
|
289
|
+
turn: null,
|
|
290
|
+
released: false,
|
|
291
|
+
};
|
|
292
|
+
this.#consumersByPrompt.set(key, consumer);
|
|
293
|
+
return () => {
|
|
294
|
+
if (consumer.released) return;
|
|
295
|
+
consumer.released = true;
|
|
296
|
+
if (this.#consumersByPrompt.get(key) === consumer) {
|
|
297
|
+
this.#consumersByPrompt.delete(key);
|
|
298
|
+
}
|
|
299
|
+
if (consumer.turn !== null) {
|
|
300
|
+
const keyForTurn = turnKey(sessionId, consumer.turn);
|
|
301
|
+
if (this.#consumersByTurn.get(keyForTurn) === consumer) {
|
|
302
|
+
this.#consumersByTurn.delete(keyForTurn);
|
|
303
|
+
}
|
|
304
|
+
// Claimed artifacts have already crossed into the provider pipeline and
|
|
305
|
+
// are released there. discard() only removes unclaimed work.
|
|
306
|
+
this.discard(sessionId, consumer.turn);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Observe durable Session events solely to terminate unclaimed snapshots. */
|
|
312
|
+
observeSessionEvent(session, event) {
|
|
313
|
+
const sessionId = sessionIdOf(session);
|
|
314
|
+
if (!sessionId || !event || typeof event !== 'object') return;
|
|
315
|
+
if (event.type === 'turn/start') {
|
|
316
|
+
const turn = event.data?.turn;
|
|
317
|
+
if (Number.isInteger(turn) && turn >= 0) this.#openTurns.set(sessionId, turn);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (event.type === 'user/message') {
|
|
321
|
+
const rpcId = event.data?.source?.rpcId;
|
|
322
|
+
const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
|
|
323
|
+
if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
|
|
324
|
+
this.#openTurns.set(sessionId, turn);
|
|
325
|
+
const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
|
|
326
|
+
if (!consumer || consumer.released) return;
|
|
327
|
+
consumer.turn = turn;
|
|
328
|
+
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (event.type !== 'turn/end') return;
|
|
332
|
+
const turn = event.data?.turn;
|
|
333
|
+
if (!Number.isInteger(turn) || turn < 0) return;
|
|
334
|
+
if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
|
|
335
|
+
const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
|
|
336
|
+
if (!consumer || consumer.released) this.discard(sessionId, turn);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A disposed Session cannot have another channel consumer claim its files. */
|
|
340
|
+
disposeSession(session) {
|
|
341
|
+
const sessionId = sessionIdOf(session);
|
|
342
|
+
if (!sessionId) return;
|
|
343
|
+
const prefix = `${sessionId}\u0000`;
|
|
344
|
+
const artifacts = new Set();
|
|
345
|
+
for (const entries of [this.#turns, this.#stagedTurns]) {
|
|
346
|
+
for (const [key, turnArtifacts] of entries) {
|
|
347
|
+
if (!key.startsWith(prefix)) continue;
|
|
348
|
+
for (const artifact of turnArtifacts.values()) artifacts.add(artifact);
|
|
349
|
+
entries.delete(key);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const [key, consumer] of this.#consumersByPrompt) {
|
|
353
|
+
if (!key.startsWith(prefix)) continue;
|
|
354
|
+
consumer.released = true;
|
|
355
|
+
this.#consumersByPrompt.delete(key);
|
|
356
|
+
}
|
|
357
|
+
for (const key of this.#consumersByTurn.keys()) {
|
|
358
|
+
if (key.startsWith(prefix)) this.#consumersByTurn.delete(key);
|
|
359
|
+
}
|
|
360
|
+
this.#openTurns.delete(sessionId);
|
|
361
|
+
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async stage(args, exec) {
|
|
365
|
+
const requestedPath = args?.path;
|
|
366
|
+
if (typeof requestedPath !== 'string' || !requestedPath.trim()) {
|
|
367
|
+
throw new TypeError('A file path is required.');
|
|
368
|
+
}
|
|
369
|
+
const agent = exec?.agent;
|
|
370
|
+
const sessionId = agent?.session?.header?.id;
|
|
371
|
+
const workspace = agent?.session?.header?.cwd;
|
|
372
|
+
const turn = currentTurn(agent);
|
|
373
|
+
if (typeof sessionId !== 'string' || !sessionId
|
|
374
|
+
|| typeof workspace !== 'string' || !workspace || turn === null) {
|
|
375
|
+
throw artifactError(
|
|
376
|
+
'artifact-context-required',
|
|
377
|
+
'A live Harness Session is required to return a file.',
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
|
|
382
|
+
const { storagePath, ...snapshotMetadata } = snapshot;
|
|
383
|
+
const artifact = Object.freeze({
|
|
384
|
+
kind: ARTIFACT_KIND,
|
|
385
|
+
schemaVersion: 1,
|
|
386
|
+
artifactId: this.#uuid(),
|
|
387
|
+
deliveryKey: this.#uuid(),
|
|
388
|
+
...snapshotMetadata,
|
|
389
|
+
source: 'managed-temp',
|
|
390
|
+
registeredBy: Object.freeze({
|
|
391
|
+
kind: 'tool-result',
|
|
392
|
+
eventId: typeof exec?.callId === 'string' ? exec.callId : 'unknown',
|
|
393
|
+
toolName: OUTBOUND_ARTIFACT_TOOL,
|
|
394
|
+
}),
|
|
395
|
+
origin: Object.freeze({
|
|
396
|
+
sessionId,
|
|
397
|
+
turn,
|
|
398
|
+
callId: typeof exec?.callId === 'string' ? exec.callId : null,
|
|
399
|
+
}),
|
|
400
|
+
createdAt: Date.now(),
|
|
401
|
+
});
|
|
402
|
+
artifactStorage.set(artifact, {
|
|
403
|
+
path: storagePath,
|
|
404
|
+
materializing: 0,
|
|
405
|
+
materialized: false,
|
|
406
|
+
releaseRequested: false,
|
|
407
|
+
cleanupRequested: false,
|
|
408
|
+
cleanupDeferred: false,
|
|
409
|
+
onCleanup: () => this.#forgetArtifact(artifact),
|
|
410
|
+
});
|
|
411
|
+
const keyForTurn = turnKey(sessionId, turn);
|
|
412
|
+
let staged = this.#stagedTurns.get(keyForTurn);
|
|
413
|
+
if (!staged) {
|
|
414
|
+
staged = new Map();
|
|
415
|
+
this.#stagedTurns.set(keyForTurn, staged);
|
|
416
|
+
}
|
|
417
|
+
staged.set(artifactKey(artifact), artifact);
|
|
418
|
+
return artifact;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
commit(artifact) {
|
|
422
|
+
if (artifact?.kind !== ARTIFACT_KIND || !artifactStorage.has(artifact)) return null;
|
|
423
|
+
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
|
424
|
+
const key = artifactKey(artifact);
|
|
425
|
+
const staged = this.#stagedTurns.get(keyForTurn);
|
|
426
|
+
if (staged?.get(key) !== artifact) return null;
|
|
427
|
+
staged.delete(key);
|
|
428
|
+
if (staged.size === 0) this.#stagedTurns.delete(keyForTurn);
|
|
429
|
+
let committed = this.#turns.get(keyForTurn);
|
|
430
|
+
if (!committed) {
|
|
431
|
+
committed = new Map();
|
|
432
|
+
this.#turns.set(keyForTurn, committed);
|
|
433
|
+
}
|
|
434
|
+
committed.set(key, artifact);
|
|
435
|
+
return publicArtifact(artifact);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
release(artifact) {
|
|
439
|
+
if (artifact?.kind !== ARTIFACT_KIND) return;
|
|
440
|
+
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
|
441
|
+
const key = artifactKey(artifact);
|
|
442
|
+
const staged = this.#stagedTurns.get(keyForTurn);
|
|
443
|
+
if (staged?.get(key) === artifact) staged.delete(key);
|
|
444
|
+
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
|
445
|
+
cleanupArtifactStorage(artifact);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
take(sessionId, turn, { signal } = {}) {
|
|
449
|
+
const keyForTurn = turnKey(sessionId, turn);
|
|
450
|
+
const committed = this.#turns.get(keyForTurn);
|
|
451
|
+
this.#turns.delete(keyForTurn);
|
|
452
|
+
if (!committed) return [];
|
|
453
|
+
if (signal?.aborted) {
|
|
454
|
+
for (const artifact of committed.values()) cleanupArtifactStorage(artifact);
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
const claimed = this.#claimedTurns.get(keyForTurn) ?? new Map();
|
|
458
|
+
this.#claimedTurns.set(keyForTurn, claimed);
|
|
459
|
+
const artifacts = [];
|
|
460
|
+
for (const [key, artifact] of committed) {
|
|
461
|
+
if (!artifactStorage.has(artifact)) continue;
|
|
462
|
+
claimed.set(key, artifact);
|
|
463
|
+
artifacts.push(artifact);
|
|
464
|
+
}
|
|
465
|
+
if (claimed.size === 0) this.#claimedTurns.delete(keyForTurn);
|
|
466
|
+
else if (!this.#bindClaimSignal(keyForTurn, signal)) return [];
|
|
467
|
+
return artifacts;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
discard(sessionId, turn) {
|
|
471
|
+
if (typeof sessionId !== 'string' || !Number.isInteger(turn)) return;
|
|
472
|
+
const keyForTurn = turnKey(sessionId, turn);
|
|
473
|
+
const artifacts = new Set([
|
|
474
|
+
...this.#turns.get(keyForTurn)?.values() ?? [],
|
|
475
|
+
...this.#stagedTurns.get(keyForTurn)?.values() ?? [],
|
|
476
|
+
]);
|
|
477
|
+
this.#turns.delete(keyForTurn);
|
|
478
|
+
this.#stagedTurns.delete(keyForTurn);
|
|
479
|
+
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
clear() {
|
|
483
|
+
const artifacts = new Set();
|
|
484
|
+
for (const entries of this.#turns.values()) {
|
|
485
|
+
for (const artifact of entries.values()) artifacts.add(artifact);
|
|
486
|
+
}
|
|
487
|
+
for (const entries of this.#stagedTurns.values()) {
|
|
488
|
+
for (const artifact of entries.values()) artifacts.add(artifact);
|
|
489
|
+
}
|
|
490
|
+
for (const entries of this.#claimedTurns.values()) {
|
|
491
|
+
for (const artifact of entries.values()) artifacts.add(artifact);
|
|
492
|
+
}
|
|
493
|
+
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
|
|
494
|
+
this.#turns.clear();
|
|
495
|
+
this.#stagedTurns.clear();
|
|
496
|
+
this.#claimedTurns.clear();
|
|
497
|
+
for (const turnKey of this.#claimSignals.keys()) this.#releaseClaimSignal(turnKey);
|
|
498
|
+
for (const consumer of this.#consumersByPrompt.values()) consumer.released = true;
|
|
499
|
+
this.#consumersByPrompt.clear();
|
|
500
|
+
this.#consumersByTurn.clear();
|
|
501
|
+
this.#openTurns.clear();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#bindClaimSignal(turnKey, signal) {
|
|
505
|
+
if (!signal) return true;
|
|
506
|
+
this.#releaseClaimSignal(turnKey);
|
|
507
|
+
let claim = this.#signalClaims.get(signal);
|
|
508
|
+
if (!claim) {
|
|
509
|
+
claim = { turnKeys: new Set(), onAbort: null };
|
|
510
|
+
claim.onAbort = () => {
|
|
511
|
+
for (const claimedTurnKey of [...claim.turnKeys]) {
|
|
512
|
+
for (const artifact of this.#claimedTurns.get(claimedTurnKey)?.values() ?? []) {
|
|
513
|
+
cleanupArtifactStorage(artifact);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
this.#signalClaims.set(signal, claim);
|
|
518
|
+
signal.addEventListener('abort', claim.onAbort, { once: true });
|
|
519
|
+
}
|
|
520
|
+
claim.turnKeys.add(turnKey);
|
|
521
|
+
this.#claimSignals.set(turnKey, signal);
|
|
522
|
+
if (signal.aborted) {
|
|
523
|
+
claim.onAbort();
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#releaseClaimSignal(turnKey) {
|
|
530
|
+
const signal = this.#claimSignals.get(turnKey);
|
|
531
|
+
if (!signal) return;
|
|
532
|
+
this.#claimSignals.delete(turnKey);
|
|
533
|
+
const claim = this.#signalClaims.get(signal);
|
|
534
|
+
claim?.turnKeys.delete(turnKey);
|
|
535
|
+
if (claim?.turnKeys.size === 0) {
|
|
536
|
+
signal.removeEventListener('abort', claim.onAbort);
|
|
537
|
+
this.#signalClaims.delete(signal);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#forgetArtifact(artifact) {
|
|
542
|
+
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
|
|
543
|
+
const key = artifactKey(artifact);
|
|
544
|
+
const committed = this.#turns.get(keyForTurn);
|
|
545
|
+
if (committed?.get(key) === artifact) committed.delete(key);
|
|
546
|
+
if (committed?.size === 0) this.#turns.delete(keyForTurn);
|
|
547
|
+
const staged = this.#stagedTurns.get(keyForTurn);
|
|
548
|
+
if (staged?.get(key) === artifact) staged.delete(key);
|
|
549
|
+
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
|
|
550
|
+
const claimed = this.#claimedTurns.get(keyForTurn);
|
|
551
|
+
if (claimed?.get(key) === artifact) claimed.delete(key);
|
|
552
|
+
if (claimed?.size === 0) {
|
|
553
|
+
this.#claimedTurns.delete(keyForTurn);
|
|
554
|
+
this.#releaseClaimSignal(keyForTurn);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export const outboundArtifactRegistry = new OutboundArtifactRegistry();
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Build a two-phase tool: execute stages a file; the authoritative tools/result
|
|
564
|
+
* observer commits only a successful native call or successful Code Mode parent.
|
|
565
|
+
*/
|
|
566
|
+
export function createOutboundArtifactTool({ registry = outboundArtifactRegistry } = {}) {
|
|
567
|
+
const staged = new WeakMap();
|
|
568
|
+
const pendingByParent = new Map();
|
|
569
|
+
const appendPending = (parent, artifact) => {
|
|
570
|
+
let pending = pendingByParent.get(parent);
|
|
571
|
+
if (!pending) {
|
|
572
|
+
pending = { artifacts: [] };
|
|
573
|
+
pendingByParent.set(parent, pending);
|
|
574
|
+
}
|
|
575
|
+
pending.artifacts.push(artifact);
|
|
576
|
+
};
|
|
577
|
+
const definition = Object.freeze({
|
|
578
|
+
name: OUTBOUND_ARTIFACT_TOOL,
|
|
579
|
+
description: 'Send a readable file to the user through the current conversation. Existing and newly created files are both valid.',
|
|
580
|
+
parameters: {
|
|
581
|
+
type: 'object',
|
|
582
|
+
additionalProperties: false,
|
|
583
|
+
properties: {
|
|
584
|
+
path: {
|
|
585
|
+
type: 'string',
|
|
586
|
+
description: 'Absolute path, or a path relative to the current workspace.',
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
required: ['path'],
|
|
590
|
+
},
|
|
591
|
+
output: {
|
|
592
|
+
schema: {
|
|
593
|
+
type: 'object',
|
|
594
|
+
additionalProperties: false,
|
|
595
|
+
properties: {
|
|
596
|
+
artifactId: { type: 'string' },
|
|
597
|
+
fileName: { type: 'string' },
|
|
598
|
+
size: { type: 'number' },
|
|
599
|
+
},
|
|
600
|
+
required: ['artifactId', 'fileName', 'size'],
|
|
601
|
+
},
|
|
602
|
+
render: (_args, value) => [{
|
|
603
|
+
type: 'text',
|
|
604
|
+
text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery.`,
|
|
605
|
+
}],
|
|
606
|
+
},
|
|
607
|
+
async execute(args, exec) {
|
|
608
|
+
const artifact = await registry.stage(args, exec);
|
|
609
|
+
staged.set(exec, artifact);
|
|
610
|
+
return publicArtifact(artifact);
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const onResult = (exec, result) => {
|
|
615
|
+
if (exec?.name === OUTBOUND_ARTIFACT_TOOL) {
|
|
616
|
+
const artifact = staged.get(exec);
|
|
617
|
+
if (!artifact) return;
|
|
618
|
+
staged.delete(exec);
|
|
619
|
+
if (result?.isError) {
|
|
620
|
+
registry.release(artifact);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (exec.parent === undefined) registry.commit(artifact);
|
|
624
|
+
else appendPending(exec.parent, artifact);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const pending = pendingByParent.get(exec?.token);
|
|
628
|
+
if (!pending) return;
|
|
629
|
+
pendingByParent.delete(exec.token);
|
|
630
|
+
if (result?.isError) {
|
|
631
|
+
for (const artifact of pending.artifacts) registry.release(artifact);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (exec.parent !== undefined) {
|
|
635
|
+
for (const artifact of pending.artifacts) appendPending(exec.parent, artifact);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
for (const artifact of pending.artifacts) registry.commit(artifact);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
return Object.freeze({ definition, onResult });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Register the file-return tool without a per-request Gate. */
|
|
645
|
+
export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
|
|
646
|
+
if (typeof ctx?.tools?.register !== 'function'
|
|
647
|
+
|| typeof ctx?.systemPrompt?.section !== 'function'
|
|
648
|
+
|| typeof ctx?.on !== 'function') return false;
|
|
649
|
+
const tool = createOutboundArtifactTool({ registry });
|
|
650
|
+
ctx.tools.register(tool.definition);
|
|
651
|
+
ctx.on('tools/result', tool.onResult);
|
|
652
|
+
ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
|
|
653
|
+
ctx.on('session/disposed', (session) => registry.disposeSession(session));
|
|
654
|
+
ctx.systemPrompt.section({
|
|
655
|
+
name: 'dsh-im:return-file',
|
|
656
|
+
order: 115,
|
|
657
|
+
text: `When the user asks to receive a file, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
|
|
658
|
+
});
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Materialize the registered snapshot for the channel provider. */
|
|
663
|
+
export async function materializeOutboundArtifact(artifact, {
|
|
664
|
+
signal,
|
|
665
|
+
} = {}) {
|
|
666
|
+
if (signal?.aborted) {
|
|
667
|
+
cleanupArtifactStorage(artifact);
|
|
668
|
+
signal.throwIfAborted();
|
|
669
|
+
}
|
|
670
|
+
if (artifact?.kind !== ARTIFACT_KIND || artifact.schemaVersion !== 1
|
|
671
|
+
|| !artifactStorage.has(artifact)
|
|
672
|
+
|| typeof artifact.digest !== 'string'
|
|
673
|
+
|| !Number.isSafeInteger(artifact.size) || artifact.size < 0) {
|
|
674
|
+
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
|
675
|
+
}
|
|
676
|
+
const storage = artifactStorage.get(artifact);
|
|
677
|
+
storage.materializing += 1;
|
|
678
|
+
let handle;
|
|
679
|
+
let materialized = false;
|
|
680
|
+
try {
|
|
681
|
+
const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
|
|
682
|
+
handle = await open(storage.path, fsConstants.O_RDONLY | noFollow);
|
|
683
|
+
const before = await handle.stat({ bigint: true });
|
|
684
|
+
if (!before.isFile() || before.size !== BigInt(artifact.size)) {
|
|
685
|
+
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
|
686
|
+
}
|
|
687
|
+
const bytes = await readExactArtifactFile(handle, artifact.size, {
|
|
688
|
+
signal,
|
|
689
|
+
errorCode: 'artifact-invalid',
|
|
690
|
+
errorMessage: 'The file registration is invalid.',
|
|
691
|
+
});
|
|
692
|
+
const after = await handle.stat({ bigint: true });
|
|
693
|
+
if (!sameIdentity(before, after)
|
|
694
|
+
|| bytes.byteLength !== artifact.size
|
|
695
|
+
|| sha256(bytes) !== artifact.digest) {
|
|
696
|
+
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
|
697
|
+
}
|
|
698
|
+
signal?.throwIfAborted();
|
|
699
|
+
materialized = true;
|
|
700
|
+
const file = Object.freeze({
|
|
701
|
+
artifactId: artifact.artifactId,
|
|
702
|
+
deliveryKey: artifact.deliveryKey,
|
|
703
|
+
fileName: artifact.fileName,
|
|
704
|
+
mediaType: artifact.mediaType,
|
|
705
|
+
size: artifact.size,
|
|
706
|
+
bytes,
|
|
707
|
+
});
|
|
708
|
+
materializedArtifactSources.set(file, artifact);
|
|
709
|
+
storage.materialized = true;
|
|
710
|
+
return file;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error?.code?.startsWith?.('artifact-')) throw error;
|
|
713
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
714
|
+
throw artifactError('artifact-invalid', 'The file registration is invalid.');
|
|
715
|
+
} finally {
|
|
716
|
+
await handle?.close().catch(() => undefined);
|
|
717
|
+
storage.materializing -= 1;
|
|
718
|
+
if (!materialized || storage.cleanupRequested) {
|
|
719
|
+
cleanupArtifactStorage(artifact);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Keep a materialized snapshot alive until an unabortable provider call settles. */
|
|
725
|
+
export function trackOutboundArtifactProviderPromise(file, promise) {
|
|
726
|
+
const artifact = materializedArtifactSources.get(file);
|
|
727
|
+
if (!artifact || !artifactStorage.has(artifact)
|
|
728
|
+
|| !promise || typeof promise.then !== 'function') return promise;
|
|
729
|
+
const settlements = artifactProviderSettlements.get(artifact) ?? new Set();
|
|
730
|
+
const settlement = Promise.resolve(promise).then(
|
|
731
|
+
() => undefined,
|
|
732
|
+
() => undefined,
|
|
733
|
+
);
|
|
734
|
+
settlements.add(settlement);
|
|
735
|
+
artifactProviderSettlements.set(artifact, settlements);
|
|
736
|
+
void settlement.finally(() => {
|
|
737
|
+
settlements.delete(settlement);
|
|
738
|
+
if (settlements.size === 0) artifactProviderSettlements.delete(artifact);
|
|
739
|
+
});
|
|
740
|
+
return promise;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Release a claimed snapshot after its provider send reaches a terminal result. */
|
|
744
|
+
export function releaseOutboundArtifact(artifact) {
|
|
745
|
+
const storage = artifactStorage.get(artifact);
|
|
746
|
+
if (storage) storage.releaseRequested = true;
|
|
747
|
+
cleanupArtifactStorage(artifact);
|
|
748
|
+
}
|