pi-roundtable 0.7.2 → 0.7.4
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 +29 -0
- package/docs/plugins.md +12 -0
- package/package.json +1 -1
- package/src/core/attachments/image-budget.ts +158 -0
- package/src/core/attachments/image-prep.ts +35 -6
- package/src/core/attachments/image-preparation-error.ts +14 -0
- package/src/index.ts +1 -0
- package/src/kit/channels.ts +2 -0
- package/src/kit/diagnostics.ts +43 -0
- package/src/kit/index.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.4] - 2026-10-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Export `scrubDiagnostic` from the kit: mask credentials in subprocess or worker text (URL userinfo, bearer values, well-known token shapes, secret-named assignments), drop control characters and bound the length, so a host can show a failure's reason without its secrets.
|
|
13
|
+
- Coding: `toolText` for the repository tools' descriptions and argument descriptions, `presentation.list` for the `repo_list` result, and `repo_push` takes a 7–40 character sha prefix of the reported commit.
|
|
14
|
+
- Sandbox: `SandboxResearchWorker` accepts the host's own `tools` (extension packages and factories, active tool names, prompt), a `scope` around the session and the `aborted` wording.
|
|
15
|
+
- Drawing: `cardPresentation.result` words the card draw result, and `mapLimits` lets a host accept longer relationship-map text.
|
|
16
|
+
- Web: a channel Discord cannot name shows the `channel name unavailable` text.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Coding: git and gh failures name their scrubbed stderr again, a worker's own error reaches the report (scrubbed and bounded), the timeout and stop texts read as before ("the worker ran out of time (N minutes)", "the worker was stopped"), the refusals for a busy repository, a missing or over-long task and a full channel say what is wrong, a declined call is no longer listed as held, and the service reports the channels it works for so a shutdown can name them.
|
|
21
|
+
- Sandbox: `ScopedSandboxDelegator` refuses with the reasons the core delegator gave ("title and task are required", the task length, the channel's running count), no longer limits the title, and reports a failed job's scrubbed error message or "the worker ran out of time" instead of "Sandbox task failed".
|
|
22
|
+
|
|
23
|
+
## [0.7.3] - 2026-10-03
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- The sandbox's rich broker streams model responses instead of buffering them and no longer cuts calls at 60 or 120 seconds; the turn deadline is the bound. The worker can no longer raise effort or thinking budget above the host-judged level. `startFresh` and start replace guest-planted symlinks instead of following them, and only the two reserved blocks of the 192.0 range are refused, not all of it.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Sandbox `assertPublicUrl`, `safeFetch` options `followRedirects` and `headers`, and a research-worker `fetchContent` hook, so a host can keep a library's own extractors while its fetches of a model-supplied URL stay pinned and vetted per hop. Broker upstream errors keep their status, body and back-off headers.
|
|
32
|
+
- Coding accepts `pushHoldText` for trusted wording of the held `repo_push` approval card.
|
|
33
|
+
- Export `withReplyFiles` for standalone workers to collect reply attachments with the same turn-wide limits, success/failure semantics, and asynchronous scope as host conversation turns.
|
|
34
|
+
- Export `prepareImageBytes` and `ImagePreparationError` from the kit for raster bytes without reopening guest-writable paths; reject encoded images over 25 MiB or headers/aggregate frames over 64 MP before native decode, while preserving ordinary 48 MP images and 9000×1 downscaling. File-based preparation is byte-bounded too and decodes once instead of twice.
|
|
35
|
+
- Drawing accepts `CardPresentation` through `DrawingOptions.cardPresentation` for trusted operator headings and reversal suffixes without changing tool arguments or default captions.
|
|
36
|
+
|
|
8
37
|
## [0.7.2] - 2026-10-02
|
|
9
38
|
|
|
10
39
|
### Fixed
|
package/docs/plugins.md
CHANGED
|
@@ -55,6 +55,11 @@ Import fixtures and fake threads from `pi-roundtable/testing` for tests.
|
|
|
55
55
|
Use the kit's building blocks for a plugin that runs Pi itself, such as a coding worker:
|
|
56
56
|
|
|
57
57
|
- MCP: `mcpExtension` and `VirtualServer` expose MCP servers to a session, and `mcpAdapterExtension` and `readAttachmentExtension` do the same inside an out-of-process worker.
|
|
58
|
+
- Admitted media: `prepareImageBytes(data, contentType)` prepares raster bytes without reopening a guest-writable path; supported small images remain unchanged, and larger sides/encoded images are downsized to JPEG at a 2,000 px long side.
|
|
59
|
+
Before any native decoder, it refuses more than 25 MiB of encoded bytes or 64 million decoded pixels, including aggregate GIF/WebP frame dimensions.
|
|
60
|
+
`ImagePreparationError.reason` is `byte-limit`, `pixel-limit`, or `invalid-image`; hosts can localize refusals without parsing native decoder errors.
|
|
61
|
+
Ordinary 48 MP images and thin 9000×1 images remain admitted.
|
|
62
|
+
This helper is not a downloader or filesystem validator.
|
|
58
63
|
- Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
|
|
59
64
|
- Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
|
|
60
65
|
- Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
|
|
@@ -429,6 +434,9 @@ A Pi package should use the host's peer dependency on `pi-roundtable`, not bundl
|
|
|
429
434
|
Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
|
|
430
435
|
Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
|
|
431
436
|
Await work that produces files before returning from the tool.
|
|
437
|
+
A standalone isolated worker may wrap its complete awaited turn in `withReplyFiles(supported, run)` from the main entry.
|
|
438
|
+
`run` returns `Promise<TurnResult>`; the collector validates any returned files, includes accepted attachments only on success, and deactivates on success, failure, or throw.
|
|
439
|
+
The caller must deliver the returned `files` and must not open a fresh collector per tool to evade turn-wide limits.
|
|
432
440
|
|
|
433
441
|
| Case | Behavior |
|
|
434
442
|
|---|---|
|
|
@@ -2479,6 +2487,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
2479
2487
|
| `TurnSelection` | `pi-roundtable` | type |
|
|
2480
2488
|
| `Weekday` | `pi-roundtable` | type |
|
|
2481
2489
|
| `attachReplyFile` | `pi-roundtable` | value |
|
|
2490
|
+
| `withReplyFiles` | `pi-roundtable` | value |
|
|
2491
|
+
| `prepareImageBytes` | `pi-roundtable/kit` | value |
|
|
2492
|
+
| `ImagePreparationError` | `pi-roundtable/kit` | value |
|
|
2493
|
+
| `scrubDiagnostic` | `pi-roundtable/kit` | value |
|
|
2482
2494
|
| `channelKey` | `pi-roundtable` | value |
|
|
2483
2495
|
| `definePlugin` | `pi-roundtable` | value |
|
|
2484
2496
|
| `defineRoundtable` | `pi-roundtable` | value |
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ImagePreparationError } from "./image-preparation-error.ts";
|
|
2
|
+
|
|
3
|
+
const invalid = () => new ImagePreparationError("invalid-image");
|
|
4
|
+
|
|
5
|
+
/** Inspect only encoded headers/blocks. Never allocate a raster or follow a media URL. */
|
|
6
|
+
export function assertImagePixelBudget(bytes: Buffer, budget: number): void {
|
|
7
|
+
const pixels = (width: number, height: number): number => {
|
|
8
|
+
if (width < 1 || height < 1) throw invalid();
|
|
9
|
+
const area = width * height;
|
|
10
|
+
check(area);
|
|
11
|
+
return area;
|
|
12
|
+
};
|
|
13
|
+
const check = (area: number) => {
|
|
14
|
+
if (area > budget) throw new ImagePreparationError("pixel-limit");
|
|
15
|
+
};
|
|
16
|
+
const need = (end: number, limit = bytes.length) => {
|
|
17
|
+
if (end > limit) throw invalid();
|
|
18
|
+
};
|
|
19
|
+
const u24 = (at: number) => bytes.readUIntLE(at, 3);
|
|
20
|
+
const blockEnd = (start: number): number => {
|
|
21
|
+
let at = start;
|
|
22
|
+
for (;;) {
|
|
23
|
+
need(at + 1);
|
|
24
|
+
const length = bytes[at++] ?? 0;
|
|
25
|
+
if (!length) return at;
|
|
26
|
+
at += length;
|
|
27
|
+
need(at);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
33
|
+
) {
|
|
34
|
+
need(24);
|
|
35
|
+
if (
|
|
36
|
+
bytes.readUInt32BE(8) !== 13 ||
|
|
37
|
+
bytes.toString("ascii", 12, 16) !== "IHDR"
|
|
38
|
+
)
|
|
39
|
+
throw invalid();
|
|
40
|
+
pixels(bytes.readUInt32BE(16), bytes.readUInt32BE(20));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
44
|
+
let at = 2;
|
|
45
|
+
while (at < bytes.length) {
|
|
46
|
+
if (bytes[at++] !== 0xff) throw invalid();
|
|
47
|
+
while (bytes[at] === 0xff) at++;
|
|
48
|
+
need(at + 1);
|
|
49
|
+
const marker = bytes[at++] ?? 0;
|
|
50
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
51
|
+
if (marker === 1 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7))
|
|
52
|
+
continue;
|
|
53
|
+
need(at + 2);
|
|
54
|
+
const length = bytes.readUInt16BE(at);
|
|
55
|
+
if (length < 2) throw invalid();
|
|
56
|
+
need(at + length);
|
|
57
|
+
if (
|
|
58
|
+
marker >= 0xc0 &&
|
|
59
|
+
marker <= 0xcf &&
|
|
60
|
+
![0xc4, 0xc8, 0xcc].includes(marker)
|
|
61
|
+
) {
|
|
62
|
+
if (length < 8) throw invalid();
|
|
63
|
+
pixels(bytes.readUInt16BE(at + 5), bytes.readUInt16BE(at + 3));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
at += length;
|
|
67
|
+
}
|
|
68
|
+
throw invalid();
|
|
69
|
+
}
|
|
70
|
+
if (["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) {
|
|
71
|
+
need(13);
|
|
72
|
+
pixels(bytes.readUInt16LE(6), bytes.readUInt16LE(8));
|
|
73
|
+
let at =
|
|
74
|
+
13 + ((bytes[10] ?? 0) & 0x80 ? 3 * (2 << ((bytes[10] ?? 0) & 7)) : 0);
|
|
75
|
+
let total = 0;
|
|
76
|
+
need(at);
|
|
77
|
+
while (at < bytes.length) {
|
|
78
|
+
const marker = bytes[at++];
|
|
79
|
+
if (marker === 0x3b) {
|
|
80
|
+
if (!total) throw invalid();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (marker === 0x21) {
|
|
84
|
+
need(at + 1);
|
|
85
|
+
at = blockEnd(at + 1);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (marker !== 0x2c) throw invalid();
|
|
89
|
+
need(at + 9);
|
|
90
|
+
total += pixels(bytes.readUInt16LE(at + 4), bytes.readUInt16LE(at + 6));
|
|
91
|
+
check(total);
|
|
92
|
+
const flags = bytes[at + 8] ?? 0;
|
|
93
|
+
at += 9 + (flags & 0x80 ? 3 * (2 << (flags & 7)) : 0);
|
|
94
|
+
need(at + 1);
|
|
95
|
+
at = blockEnd(at + 1);
|
|
96
|
+
}
|
|
97
|
+
throw invalid();
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
bytes.toString("ascii", 0, 4) === "RIFF" &&
|
|
101
|
+
bytes.toString("ascii", 8, 12) === "WEBP"
|
|
102
|
+
) {
|
|
103
|
+
need(12);
|
|
104
|
+
const end = bytes.readUInt32LE(4) + 8;
|
|
105
|
+
need(end);
|
|
106
|
+
if (end < 12) throw invalid();
|
|
107
|
+
const chunks = (start: number, limit: number, frames: boolean): number => {
|
|
108
|
+
let at = start,
|
|
109
|
+
canvas = 0,
|
|
110
|
+
total = 0;
|
|
111
|
+
while (at < limit) {
|
|
112
|
+
need(at + 8, limit);
|
|
113
|
+
const type = bytes.toString("ascii", at, at + 4);
|
|
114
|
+
const size = bytes.readUInt32LE(at + 4);
|
|
115
|
+
const data = at + 8,
|
|
116
|
+
next = data + size;
|
|
117
|
+
need(next, limit);
|
|
118
|
+
let area = 0;
|
|
119
|
+
if (type === "VP8X") {
|
|
120
|
+
need(data + 10, next);
|
|
121
|
+
canvas = pixels(u24(data + 4) + 1, u24(data + 7) + 1);
|
|
122
|
+
} else if (type === "VP8 ") {
|
|
123
|
+
need(data + 10, next);
|
|
124
|
+
if (
|
|
125
|
+
!bytes
|
|
126
|
+
.subarray(data + 3, data + 6)
|
|
127
|
+
.equals(Buffer.from([0x9d, 0x01, 0x2a]))
|
|
128
|
+
)
|
|
129
|
+
throw invalid();
|
|
130
|
+
area = pixels(
|
|
131
|
+
bytes.readUInt16LE(data + 6) & 0x3fff,
|
|
132
|
+
bytes.readUInt16LE(data + 8) & 0x3fff,
|
|
133
|
+
);
|
|
134
|
+
} else if (type === "VP8L") {
|
|
135
|
+
need(data + 5, next);
|
|
136
|
+
if (bytes[data] !== 0x2f) throw invalid();
|
|
137
|
+
const bits = bytes.readUInt32LE(data + 1);
|
|
138
|
+
area = pixels((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
|
|
139
|
+
} else if (type === "ANMF") {
|
|
140
|
+
if (!frames) throw invalid();
|
|
141
|
+
need(data + 16, next);
|
|
142
|
+
const declared = pixels(u24(data + 6) + 1, u24(data + 9) + 1);
|
|
143
|
+
area = Math.max(declared, chunks(data + 16, next, false));
|
|
144
|
+
}
|
|
145
|
+
total += area;
|
|
146
|
+
check(Math.max(total, canvas));
|
|
147
|
+
at = next + (size & 1);
|
|
148
|
+
need(at, limit);
|
|
149
|
+
}
|
|
150
|
+
const result = Math.max(total, canvas);
|
|
151
|
+
if (!result) throw invalid();
|
|
152
|
+
return result;
|
|
153
|
+
};
|
|
154
|
+
chunks(12, end, true);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw invalid();
|
|
158
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createCanvas, loadImage } from "canvas";
|
|
2
2
|
import type { ModelImage, StoredAttachment } from "../domain/attachment.ts";
|
|
3
|
+
import { MAX_ATTACHMENT_BYTES } from "./attachment-fetcher.ts";
|
|
4
|
+
import { assertImagePixelBudget } from "./image-budget.ts";
|
|
5
|
+
import { ImagePreparationError } from "./image-preparation-error.ts";
|
|
3
6
|
|
|
4
7
|
/** Images the model accepts as images; other files stay files. */
|
|
5
8
|
const IMAGE_TYPES = new Set([
|
|
@@ -29,15 +32,41 @@ export function isModelImage(
|
|
|
29
32
|
export async function prepareImage(
|
|
30
33
|
file: Pick<StoredAttachment, "path" | "contentType">,
|
|
31
34
|
): Promise<ModelImage> {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const reader = Bun.file(file.path).stream().getReader();
|
|
36
|
+
const chunks: Uint8Array[] = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
try {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { value, done } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
size += value.byteLength;
|
|
43
|
+
if (size > MAX_ATTACHMENT_BYTES)
|
|
44
|
+
throw new ImagePreparationError("byte-limit");
|
|
45
|
+
chunks.push(value);
|
|
38
46
|
}
|
|
47
|
+
} finally {
|
|
48
|
+
await reader.cancel();
|
|
39
49
|
}
|
|
50
|
+
return prepareImageBytes(Buffer.concat(chunks, size), file.contentType);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Prepare bounded raster bytes without reopening a guest-writable filesystem path.
|
|
54
|
+
* Headers and all GIF/WebP frames must fit 64 MP; encoded bytes must fit 25 MiB. */
|
|
55
|
+
export async function prepareImageBytes(
|
|
56
|
+
data: Uint8Array,
|
|
57
|
+
contentType: string,
|
|
58
|
+
): Promise<ModelImage> {
|
|
59
|
+
if (data.byteLength > MAX_ATTACHMENT_BYTES)
|
|
60
|
+
throw new ImagePreparationError("byte-limit");
|
|
61
|
+
const bytes = Buffer.from(data);
|
|
62
|
+
assertImagePixelBudget(bytes, MAX_SIDE * MAX_SIDE);
|
|
63
|
+
const mimeType = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
40
64
|
const image = await loadImage(bytes);
|
|
65
|
+
if (
|
|
66
|
+
bytes.byteLength <= MAX_RAW_BYTES &&
|
|
67
|
+
Math.max(image.width, image.height) <= MAX_SIDE
|
|
68
|
+
)
|
|
69
|
+
return { data: bytes.toString("base64"), mimeType };
|
|
41
70
|
const scale = DOWNSCALED_SIDE / Math.max(image.width, image.height);
|
|
42
71
|
const width = Math.max(1, Math.round(image.width * Math.min(1, scale)));
|
|
43
72
|
const height = Math.max(1, Math.round(image.height * Math.min(1, scale)));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Image admission failed before native decoding or allocating a bitmap. */
|
|
2
|
+
export class ImagePreparationError extends Error {
|
|
3
|
+
override readonly name = "ImagePreparationError";
|
|
4
|
+
|
|
5
|
+
constructor(readonly reason: "byte-limit" | "pixel-limit" | "invalid-image") {
|
|
6
|
+
super(
|
|
7
|
+
{
|
|
8
|
+
"byte-limit": "Image is larger than 25 MiB.",
|
|
9
|
+
"pixel-limit": "Image exceeds the 64 MP decoded-pixel budget.",
|
|
10
|
+
"invalid-image": "Image has an invalid or unsupported raster header.",
|
|
11
|
+
}[reason],
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
CHANGED
package/src/kit/channels.ts
CHANGED
|
@@ -6,6 +6,8 @@ export {
|
|
|
6
6
|
channelSegment,
|
|
7
7
|
ownerAttachmentDir,
|
|
8
8
|
} from "../core/attachments/attachment-dir.ts";
|
|
9
|
+
export { prepareImageBytes } from "../core/attachments/image-prep.ts";
|
|
10
|
+
export { ImagePreparationError } from "../core/attachments/image-preparation-error.ts";
|
|
9
11
|
export { withAttachmentsBlock } from "../core/attachments/prompt-block.ts";
|
|
10
12
|
export { attachmentsOf } from "../core/attachments/turn-attachments.ts";
|
|
11
13
|
export type { ChannelQueue } from "../core/routing/channel-queue.ts";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Tool text that may name a credential: userinfo in URLs, bearer values, and well-known token shapes. */
|
|
2
|
+
const CREDENTIALS: [RegExp, string][] = [
|
|
3
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1[redacted]@"],
|
|
4
|
+
[
|
|
5
|
+
/\b(authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi,
|
|
6
|
+
"$1: [redacted]",
|
|
7
|
+
],
|
|
8
|
+
[/\b(bearer|basic|token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]"],
|
|
9
|
+
[
|
|
10
|
+
/\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/g,
|
|
11
|
+
"[redacted]",
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|CREDENTIAL)[A-Z0-9_]*)\s*=\s*\S+/g,
|
|
15
|
+
"$1=[redacted]",
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
|
|
19
|
+
"[redacted]",
|
|
20
|
+
],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Text from a failed subprocess or worker, safe to show a model or the owner: credentials are
|
|
25
|
+
* masked, control characters are removed, and the length is bounded. Empty input returns "".
|
|
26
|
+
*/
|
|
27
|
+
export function scrubDiagnostic(text: string, max = 600): string {
|
|
28
|
+
let clean = text;
|
|
29
|
+
for (const [pattern, replacement] of CREDENTIALS)
|
|
30
|
+
clean = clean.replace(pattern, replacement);
|
|
31
|
+
clean = Array.from(clean, (char) => {
|
|
32
|
+
const code = char.charCodeAt(0);
|
|
33
|
+
// Keep tab and newline; every other control character becomes a space.
|
|
34
|
+
return code === 9 || code === 10 || code > 31
|
|
35
|
+
? code === 127
|
|
36
|
+
? " "
|
|
37
|
+
: char
|
|
38
|
+
: " ";
|
|
39
|
+
})
|
|
40
|
+
.join("")
|
|
41
|
+
.trim();
|
|
42
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
43
|
+
}
|
package/src/kit/index.ts
CHANGED
|
@@ -6,12 +6,15 @@ export {
|
|
|
6
6
|
channelQueue,
|
|
7
7
|
channelSegment,
|
|
8
8
|
discordKey,
|
|
9
|
+
ImagePreparationError,
|
|
9
10
|
outcome,
|
|
10
11
|
ownerAttachmentDir,
|
|
12
|
+
prepareImageBytes,
|
|
11
13
|
settleTurn,
|
|
12
14
|
withAttachmentsBlock,
|
|
13
15
|
withReference,
|
|
14
16
|
} from "./channels.ts";
|
|
17
|
+
export { scrubDiagnostic } from "./diagnostics.ts";
|
|
15
18
|
export type {
|
|
16
19
|
ChoiceAnswer,
|
|
17
20
|
ChoiceQuestion,
|