@kammcs/buildit-mcp 0.8.0 → 0.10.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/CHANGELOG.md +66 -0
- package/README.md +38 -6
- package/dist/api/generated/operations.js +156 -2
- package/dist/api/generated/schemas.js +337 -109
- package/dist/api/generated/strict.js +371 -109
- package/dist/channel/attachment-blocks.js +57 -0
- package/dist/channel/attachments.js +469 -0
- package/dist/channel/handoffs.js +11 -22
- package/dist/channel/item-events.js +10 -15
- package/dist/channel/loop.js +87 -2
- package/dist/channel/mask.js +61 -1
- package/dist/channel/threads.js +21 -0
- package/dist/channel/upload.js +374 -0
- package/dist/cli/commands.js +3 -0
- package/dist/errors.js +1 -0
- package/dist/files/location.js +1855 -0
- package/dist/files/paths.js +69 -0
- package/dist/files/poster.js +281 -0
- package/dist/files/scan.js +108 -0
- package/dist/files/transfer.js +163 -0
- package/dist/files/types.js +351 -0
- package/dist/log.js +7 -0
- package/dist/server.js +9 -0
- package/dist/tools/ask.js +237 -223
- package/dist/tools/attachment.js +175 -0
- package/dist/tools/reply.js +216 -62
- package/dist/transports/stdio.js +9 -3
- package/package.json +1 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sharing local files in a reply: the checks, then the upload, then one post.
|
|
3
|
+
*
|
|
4
|
+
* 1. **Checks, before any network call**, for every file: it resolves (its
|
|
5
|
+
* real path, symlinks followed) to a regular file inside the session's
|
|
6
|
+
* folder or the OS temp folder, and not in a secret folder; its name is
|
|
7
|
+
* not a secret store's; it is not a program (by its extension, its type
|
|
8
|
+
* or its first bytes); it is within the size cap for its type; and a
|
|
9
|
+
* text file passes the whole-file credential scan. One refusal refuses
|
|
10
|
+
* the reply: nothing is uploaded or posted, and the error says why.
|
|
11
|
+
* 2. **Location** is removed from photos and videos into a temporary copy
|
|
12
|
+
* (the original is never touched), as the app does for a person's. A
|
|
13
|
+
* video gets its length and a poster frame when ffprobe and ffmpeg are
|
|
14
|
+
* on the PATH (`src/files/poster.ts`); without them, neither, as before.
|
|
15
|
+
* 3. **Each file** is uploaded: create_session_upload (with a video's
|
|
16
|
+
* duration_ms and thumbnail when known), a streamed PUT with exactly the
|
|
17
|
+
* signed headers (no Authorization), the poster's PUT to thumbnail_put,
|
|
18
|
+
* complete_session_upload (nothing uploaded yet, upload_missing: the PUTs
|
|
19
|
+
* once more, then complete again). A refusal there stops the reply too,
|
|
20
|
+
* so nothing is posted.
|
|
21
|
+
* 4. **One post**, post_session_message with the text and the uploads.
|
|
22
|
+
*
|
|
23
|
+
* Upload ids are UUIDv5s of the session, the reply's idempotency key, the
|
|
24
|
+
* file's place and its real path, so a retry of the same reply neither
|
|
25
|
+
* uploads nor posts twice.
|
|
26
|
+
*/
|
|
27
|
+
import { lstat, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
import { basename, join, resolve } from 'node:path';
|
|
30
|
+
import { ApiError } from '../api/client.js';
|
|
31
|
+
import { callOperation } from '../api/operations.js';
|
|
32
|
+
import { ToolInputError } from '../errors.js';
|
|
33
|
+
import { copyWithPatches, isHeifContainer, isMovie, LocationUnreadableError, stripLocation, videoLocationPatches, } from '../files/location.js';
|
|
34
|
+
import { locationProblem } from '../files/paths.js';
|
|
35
|
+
import { videoFacts } from '../files/poster.js';
|
|
36
|
+
import { describeFinding, scanFileForCredentials } from '../files/scan.js';
|
|
37
|
+
import { TransferError, uploadFile } from '../files/transfer.js';
|
|
38
|
+
import { cleanFileName, contentTypeFor, formatMiB, isBlockedFile, isProgramHeader, isSecretName, maxBytesFor, readsAsText, sniffImage, } from '../files/types.js';
|
|
39
|
+
import { uuidV5 } from '../tools/ask.js';
|
|
40
|
+
/** The most files a reply takes (a message's set). */
|
|
41
|
+
export const REPLY_MAX_FILES = 10;
|
|
42
|
+
/** The UUIDv5 namespace of a session's upload ids. */
|
|
43
|
+
const UPLOAD_NAMESPACE = '6f1c2b8e-3d4a-5e9f-8b7c-1a2d3e4f5a6b';
|
|
44
|
+
/** How many first bytes the checks read. */
|
|
45
|
+
const HEAD_BYTES = 8 * 1024;
|
|
46
|
+
/** Reads a file's first bytes. */
|
|
47
|
+
async function head(path, n = HEAD_BYTES) {
|
|
48
|
+
const handle = await open(path, 'r');
|
|
49
|
+
try {
|
|
50
|
+
const buf = Buffer.alloc(n);
|
|
51
|
+
const { bytesRead } = await handle.read(buf, 0, n, 0);
|
|
52
|
+
return buf.subarray(0, bytesRead);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await handle.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The refusal of a whole reply: every file's problem, and that nothing was sent. */
|
|
59
|
+
function refused(problems) {
|
|
60
|
+
return new ToolInputError(`Not shared: ${problems.join(' ')} Nothing was uploaded or posted; fix that and send the reply again (or leave that file out).`);
|
|
61
|
+
}
|
|
62
|
+
/** Whether a file is a photo or a video whose location is looked for. */
|
|
63
|
+
function mediaOf(contentType, first) {
|
|
64
|
+
if (sniffImage(first) !== undefined || isHeifContainer(first))
|
|
65
|
+
return 'photo';
|
|
66
|
+
if (contentType.startsWith('image/') &&
|
|
67
|
+
(contentType === 'image/tiff' ||
|
|
68
|
+
contentType.endsWith('heic') ||
|
|
69
|
+
contentType.endsWith('heif') ||
|
|
70
|
+
contentType === 'image/avif')) {
|
|
71
|
+
return 'photo';
|
|
72
|
+
}
|
|
73
|
+
if (isMovie(first))
|
|
74
|
+
return 'video';
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Step 1: the checks of every file, before any network call. Throws one
|
|
79
|
+
* ToolInputError that names each refused file and why.
|
|
80
|
+
*/
|
|
81
|
+
export async function checkFiles(given, folder, signal) {
|
|
82
|
+
const problems = [];
|
|
83
|
+
const checked = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const raw of given) {
|
|
86
|
+
const shown = JSON.stringify(raw.length > 200 ? `${raw.slice(0, 200)}…` : raw);
|
|
87
|
+
const fail = (why) => {
|
|
88
|
+
problems.push(`${shown}: ${why}.`);
|
|
89
|
+
};
|
|
90
|
+
let path;
|
|
91
|
+
try {
|
|
92
|
+
path = await realpath(resolve(folder, raw));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
fail('no such file');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const info = await lstat(path).catch(() => undefined);
|
|
99
|
+
if (info === undefined || !info.isFile()) {
|
|
100
|
+
fail('not a regular file');
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (seen.has(path)) {
|
|
104
|
+
fail('the same file is given twice');
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
seen.add(path);
|
|
108
|
+
const where = await locationProblem(path, { folder });
|
|
109
|
+
if (where === 'secret_folder') {
|
|
110
|
+
fail("it's in a folder that holds credentials (such as .ssh or .aws), which are never shared");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (where === 'outside_roots') {
|
|
114
|
+
fail("it is outside this session's folder and the temp folder; copy it into the project (or the temp folder) first if it should be shared");
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const name = cleanFileName(basename(path));
|
|
118
|
+
if (isSecretName(basename(path)) || isSecretName(basename(raw))) {
|
|
119
|
+
fail("its name is a secret store's (.env files, keys, certificates, password databases), which are never shared");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const contentType = contentTypeFor(name);
|
|
123
|
+
if (isBlockedFile(name, contentType)) {
|
|
124
|
+
fail('programs and installers are never shared; share the source or a document about it instead');
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const first = await head(path);
|
|
128
|
+
if (isProgramHeader(first)) {
|
|
129
|
+
fail('it is a program (its first bytes say so), and programs are never shared');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const max = maxBytesFor(contentType);
|
|
133
|
+
if (info.size > max) {
|
|
134
|
+
fail(`it is ${formatMiB(info.size)}, over the ${formatMiB(max)} limit for this type; compress or split it`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (readsAsText(contentType, first)) {
|
|
138
|
+
const finding = await scanFileForCredentials(path, signal);
|
|
139
|
+
if (finding !== undefined) {
|
|
140
|
+
fail(`${describeFinding(finding)}. Remove it and try again`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
checked.push({
|
|
145
|
+
given: raw,
|
|
146
|
+
path,
|
|
147
|
+
name,
|
|
148
|
+
size: info.size,
|
|
149
|
+
contentType,
|
|
150
|
+
media: mediaOf(contentType, first),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (problems.length > 0)
|
|
154
|
+
throw refused(problems);
|
|
155
|
+
return checked;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Step 2: location removed from photos and videos into copies in a new
|
|
159
|
+
* temporary folder (returned, to be removed after). A photo or video whose
|
|
160
|
+
* metadata can't be read is refused, never sent with its location. Then a
|
|
161
|
+
* video's length and poster (from the copy without location), when ffprobe
|
|
162
|
+
* and ffmpeg are on the PATH; never a refusal.
|
|
163
|
+
*/
|
|
164
|
+
export async function prepareFiles(files, options = {}) {
|
|
165
|
+
let temp;
|
|
166
|
+
const tempDir = async () => {
|
|
167
|
+
temp ??= await mkdtemp(join(tmpdir(), 'buildit-mcp-share-'));
|
|
168
|
+
return temp;
|
|
169
|
+
};
|
|
170
|
+
const out = [];
|
|
171
|
+
const problems = [];
|
|
172
|
+
try {
|
|
173
|
+
for (const [i, f] of files.entries()) {
|
|
174
|
+
try {
|
|
175
|
+
if (f.media === 'photo') {
|
|
176
|
+
const bytes = new Uint8Array(await readFile(f.path));
|
|
177
|
+
const clean = stripLocation(bytes);
|
|
178
|
+
if (clean !== bytes) {
|
|
179
|
+
const copy = join(await tempDir(), `${i}-${f.name}`);
|
|
180
|
+
await writeFile(copy, clean, { flag: 'wx', mode: 0o600 });
|
|
181
|
+
out.push({ ...f, source: copy, sourceSize: clean.length, stripped: true });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (f.media === 'video') {
|
|
186
|
+
const patches = await videoLocationPatches(f.path);
|
|
187
|
+
if (patches.length > 0) {
|
|
188
|
+
const copy = join(await tempDir(), `${i}-${f.name}`);
|
|
189
|
+
await copyWithPatches(f.path, copy, patches);
|
|
190
|
+
out.push({ ...f, source: copy, sourceSize: f.size, stripped: true });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
out.push({ ...f, source: f.path, sourceSize: f.size, stripped: false });
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err instanceof LocationUnreadableError) {
|
|
198
|
+
problems.push(`${JSON.stringify(f.given)}: its metadata can't be read, so its location can't be removed; export it again (or as another format) and share that.`);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (temp !== undefined)
|
|
208
|
+
await rm(temp, { recursive: true, force: true });
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
if (problems.length > 0) {
|
|
212
|
+
if (temp !== undefined)
|
|
213
|
+
await rm(temp, { recursive: true, force: true });
|
|
214
|
+
throw refused(problems);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
for (const [i, f] of out.entries()) {
|
|
218
|
+
if (!f.contentType.startsWith('video/'))
|
|
219
|
+
continue;
|
|
220
|
+
const facts = await videoFacts(f.source, tempDir, i, options);
|
|
221
|
+
f.durationMs = facts.durationMs;
|
|
222
|
+
f.poster = facts.poster;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (temp !== undefined)
|
|
227
|
+
await rm(temp, { recursive: true, force: true });
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
return { files: out, temp };
|
|
231
|
+
}
|
|
232
|
+
/** What a refusal at upload means, for the file it names. */
|
|
233
|
+
function uploadRefusal(err, f, owner) {
|
|
234
|
+
const d = (err.details ?? {});
|
|
235
|
+
const name = JSON.stringify(f.given);
|
|
236
|
+
switch (err.code) {
|
|
237
|
+
case 'scope_missing':
|
|
238
|
+
return new ToolInputError(`Not shared: this session can't share files. Ask ${owner} to add Files permissions in Settings → Agents, then send the reply again. Nothing was posted.`);
|
|
239
|
+
case 'session_token_required':
|
|
240
|
+
return new ToolInputError('Not shared: files can be shared only from a connected session (buildit-mcp connect), not with a personal access token. Nothing was posted.');
|
|
241
|
+
case 'files_off':
|
|
242
|
+
return new ToolInputError('Not shared: files are turned off on this buildIt.Social server. Describe the file in text instead. Nothing was posted.');
|
|
243
|
+
case 'credential_found': {
|
|
244
|
+
const lines = Array.isArray(d.lines) ? d.lines : [];
|
|
245
|
+
const kinds = Array.isArray(d.kinds) ? d.kinds : [];
|
|
246
|
+
return refused([
|
|
247
|
+
`${name}: buildIt.Social found that ${describeFinding({ lines, kinds })}. Remove it and try again.`,
|
|
248
|
+
]);
|
|
249
|
+
}
|
|
250
|
+
case 'location_found':
|
|
251
|
+
return refused([`${name}: buildIt.Social found location metadata left in the photo.`]);
|
|
252
|
+
case 'name_not_allowed':
|
|
253
|
+
return refused([`${name}: its name is a secret store's, which is never shared.`]);
|
|
254
|
+
case 'blocked_type':
|
|
255
|
+
return refused([`${name}: programs and installers are never shared.`]);
|
|
256
|
+
case 'too_large':
|
|
257
|
+
return refused([`${name}: it is over the size limit for its type.`]);
|
|
258
|
+
case 'upload_mismatch':
|
|
259
|
+
return refused([`${name}: the file changed while it was uploading; send the reply again.`]);
|
|
260
|
+
case 'budget_exceeded':
|
|
261
|
+
return new ToolInputError(`Not shared: this session's upload budget for today is used up${typeof d.resets_at === 'string' ? ` (it resets at ${d.resets_at})` : ''}. Nothing was posted.`);
|
|
262
|
+
case 'too_many_pending':
|
|
263
|
+
return new ToolInputError('Not shared: this session has too many uploads waiting to be posted; they expire within the hour. Try again later. Nothing was posted.');
|
|
264
|
+
default:
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** The upload id of a reply's nth file. */
|
|
269
|
+
export function uploadId(sessionId, key, index, path) {
|
|
270
|
+
return uuidV5(`${sessionId}\n${key}\n${index}\n${path}`, UPLOAD_NAMESPACE);
|
|
271
|
+
}
|
|
272
|
+
/** How long a PUT may take (its URL works 10 minutes). */
|
|
273
|
+
const PUT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
274
|
+
/** Step 3: one file's upload, to ready. Returns its id. */
|
|
275
|
+
async function uploadOne(f, index, total, o) {
|
|
276
|
+
const id = uploadId(o.sessionId, o.key, index, f.path);
|
|
277
|
+
const body = {
|
|
278
|
+
id,
|
|
279
|
+
...(o.threadId === undefined ? {} : { thread_id: o.threadId }),
|
|
280
|
+
file_name: f.name,
|
|
281
|
+
content_type: f.contentType,
|
|
282
|
+
size_bytes: f.sourceSize,
|
|
283
|
+
...(f.durationMs === undefined ? {} : { duration_ms: f.durationMs }),
|
|
284
|
+
...(f.poster === undefined
|
|
285
|
+
? {}
|
|
286
|
+
: {
|
|
287
|
+
thumbnail: {
|
|
288
|
+
content_type: f.poster.contentType,
|
|
289
|
+
size_bytes: f.poster.size,
|
|
290
|
+
width: f.poster.width,
|
|
291
|
+
height: f.poster.height,
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
const init = () => callOperation(o.api, 'create_session_upload', { body }, o.apiOptions);
|
|
296
|
+
const put = async (upload) => {
|
|
297
|
+
if (upload.put === undefined)
|
|
298
|
+
throw new ToolInputError('The API gave no upload URL; send the reply again.');
|
|
299
|
+
await o.progress?.(`Uploading ${f.name} (${index + 1} of ${total})`);
|
|
300
|
+
let last = Date.now();
|
|
301
|
+
await uploadFile(upload.put.url, upload.put.headers, f.source, f.sourceSize, {
|
|
302
|
+
signal: AbortSignal.any([
|
|
303
|
+
...(o.signal ? [o.signal] : []),
|
|
304
|
+
AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
305
|
+
]),
|
|
306
|
+
onBytes: (sent) => {
|
|
307
|
+
if (Date.now() - last < 10_000)
|
|
308
|
+
return;
|
|
309
|
+
last = Date.now();
|
|
310
|
+
void o.progress?.(`Uploading ${f.name}: ${Math.floor((sent / Math.max(1, f.sourceSize)) * 100)}%`);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
// The poster, to the URL signed for it (none when the server didn't take one).
|
|
314
|
+
if (f.poster !== undefined && upload.thumbnail_put !== undefined) {
|
|
315
|
+
await uploadFile(upload.thumbnail_put.url, upload.thumbnail_put.headers, f.poster.path, f.poster.size, {
|
|
316
|
+
signal: AbortSignal.any([
|
|
317
|
+
...(o.signal ? [o.signal] : []),
|
|
318
|
+
AbortSignal.timeout(PUT_TIMEOUT_MS),
|
|
319
|
+
]),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const complete = () => callOperation(o.api, 'complete_session_upload', { path: { id }, body: {} }, o.apiOptions);
|
|
324
|
+
const first = await init();
|
|
325
|
+
if (first.upload.status !== 'uploading')
|
|
326
|
+
return id;
|
|
327
|
+
await put(first.upload);
|
|
328
|
+
try {
|
|
329
|
+
await complete();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
if (!(err instanceof ApiError) || err.code !== 'upload_missing')
|
|
333
|
+
throw err;
|
|
334
|
+
// The bytes didn't arrive: a fresh URL (the same id), the PUT again, then complete again.
|
|
335
|
+
const again = await init();
|
|
336
|
+
if (again.upload.status === 'uploading') {
|
|
337
|
+
await put(again.upload);
|
|
338
|
+
await complete();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return id;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Steps 3 and 4: uploads every prepared file, then posts them with the
|
|
345
|
+
* text in one post_session_message. Any refusal stops before the post.
|
|
346
|
+
*/
|
|
347
|
+
export async function shareFiles(files, post, o) {
|
|
348
|
+
const ids = [];
|
|
349
|
+
for (const [i, f] of files.entries()) {
|
|
350
|
+
try {
|
|
351
|
+
ids.push(await uploadOne(f, i, files.length, o));
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err instanceof ApiError) {
|
|
355
|
+
const plain = uploadRefusal(err, f, o.owner);
|
|
356
|
+
if (plain !== undefined)
|
|
357
|
+
throw plain;
|
|
358
|
+
}
|
|
359
|
+
if (err instanceof TransferError) {
|
|
360
|
+
throw new ToolInputError(`Not shared: uploading ${JSON.stringify(f.given)} failed (${err.message}). Nothing was posted; send the reply again with the same idempotency_key.`);
|
|
361
|
+
}
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const response = await callOperation(o.api, 'post_session_message', {
|
|
366
|
+
body: {
|
|
367
|
+
id: o.key,
|
|
368
|
+
...post,
|
|
369
|
+
...(o.threadId === undefined ? {} : { thread_id: o.threadId }),
|
|
370
|
+
files: ids,
|
|
371
|
+
},
|
|
372
|
+
}, o.apiOptions);
|
|
373
|
+
return { response, ids };
|
|
374
|
+
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { hostname } from 'node:os';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
|
+
import { removeFolderAttachments } from '../channel/attachments.js';
|
|
12
13
|
import { ApiError, ApiClient } from '../api/client.js';
|
|
13
14
|
import { detectCapabilities } from '../capabilities.js';
|
|
14
15
|
import { connectPoll, connectStart, getSession, POLL_CODES, revokeSession, SESSION_ENDED_CODES, } from '../channel/api.js';
|
|
@@ -382,6 +383,8 @@ export async function runDisconnect(given, io) {
|
|
|
382
383
|
// Best effort: the session ends in buildIt.Social too, before its token is forgotten here.
|
|
383
384
|
const failed = await cancel(apiFor(options, io, token));
|
|
384
385
|
await forgetSession(io.store, options.folder, options.apiUrl);
|
|
386
|
+
// The session's downloaded attachments go with it.
|
|
387
|
+
await removeFolderAttachments(options.folder);
|
|
385
388
|
io.out(failed === undefined
|
|
386
389
|
? 'Disconnected: the session is cancelled in buildIt.Social, and this folder no longer has a saved token.\n'
|
|
387
390
|
: `Disconnected: this folder no longer has a saved token. The session could not be cancelled in buildIt.Social (${failed}): revoke it there (Settings → Agents).\n`);
|
package/dist/errors.js
CHANGED
|
@@ -96,6 +96,7 @@ const RATE_BUCKETS = {
|
|
|
96
96
|
session_typing_per_minute: 'typing notices per minute for this session',
|
|
97
97
|
session_approvals_per_minute: 'tool approval calls per minute for this session',
|
|
98
98
|
session_handoffs_per_minute: 'handoff calls per minute for this session',
|
|
99
|
+
session_files_per_minute: 'file downloads and uploads per minute for this session',
|
|
99
100
|
connect_starts_per_hour: 'connections started per hour from this address',
|
|
100
101
|
connect_pending_grants: 'connections waiting for approval on this server',
|
|
101
102
|
connect_pending_per_address: 'connections waiting for approval from this network',
|