pi-roundtable-sandbox 0.26.3 → 0.27.1
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 +15 -0
- package/README.md +21 -1
- package/package.json +3 -3
- package/src/claim.ts +49 -4
- package/src/index.ts +1 -0
- package/src/plugin.ts +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.27.1] - 2026-10-31
|
|
6
|
+
|
|
7
|
+
- Release in lockstep with pi-roundtable 0.27.1; no package-specific behavior changes. The core peer range stays `>=0.27.0 <0.28.0`.
|
|
8
|
+
|
|
9
|
+
## [0.27.0] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `replyCard(text, { channel, space })`: draws a PNG card for a successful reply on the host, after `replyText` and on the text that is posted; it is set as the reply's `card`, which Discord posts before the text. `undefined` posts none; a throw or rejection is logged and the reply is posted without a card. Failure texts get no card.
|
|
14
|
+
- `peerBots: true`: answers the bots the host lists under `discord.peerBots` when they mention the bot or reply to it, like a person, within the host's round cap. Off by default; `isSandboxAddress(message, peerBots?)` takes the same flag. Needs pi-roundtable with `discord.peerBots` and `answersAuthor` (`pi-roundtable/kit`): the core peer range moves with the release.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The core peer range moves to `>=0.27.0 <0.28.0`; the package imports `answersAuthor` from the 0.27 kit.
|
|
19
|
+
|
|
5
20
|
## [0.26.3] - 2026-10-30
|
|
6
21
|
|
|
7
22
|
- Release in lockstep with pi-roundtable 0.26.3; no package-specific behavior changes. The core peer range stays `>=0.26.0 <0.27.0`.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Opt-in options (below) can also sandbox a whole Discord server, share one worksp
|
|
|
11
11
|
|
|
12
12
|
## Requirements
|
|
13
13
|
|
|
14
|
-
- Bun 1.4.4 or later and pi-roundtable `>=0.
|
|
14
|
+
- Bun 1.4.4 or later and pi-roundtable `>=0.27.0 <0.28.0`.
|
|
15
15
|
- A native Linux host with a local Docker daemon and a non-root service account permitted to run Docker.
|
|
16
16
|
- The worker UID/GID match that account; root workers are refused.
|
|
17
17
|
- A trusted OpenAI-compatible, non-streaming Chat Completions endpoint supporting function tools and `max_tokens`.
|
|
@@ -185,6 +185,26 @@ It never sees failure texts, and the history the worker keeps holds the model's
|
|
|
185
185
|
A throw or a value that is not a string fails the turn like any other failure, and the error is logged with the channel; an empty string posts nothing, as an empty model reply does.
|
|
186
186
|
It needs no new worker image.
|
|
187
187
|
|
|
188
|
+
### Reply cards
|
|
189
|
+
|
|
190
|
+
`replyCard(text, { channel, space })` returns a PNG (`Uint8Array`), `undefined`, or a promise of either, and runs on the host after `replyText`, on the text that is posted.
|
|
191
|
+
The card goes in the reply's `card`, which the Discord surface posts as its own message before the text.
|
|
192
|
+
A throw or a rejection is logged as a warning and the reply is posted without a card; failure texts get no card.
|
|
193
|
+
It needs no new worker image.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
sandbox({
|
|
197
|
+
// ...
|
|
198
|
+
replyCard: async (text, { channel }) => (text.length > 40 ? await drawCard(text, channel) : undefined),
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Answering other bots
|
|
203
|
+
|
|
204
|
+
`peerBots: true` opens the sandbox channels to the bots the host lists under `discord.peerBots` (explicit user ids, no wildcard): one that mentions the bot or replies to it gets a turn like a person, within the host's round cap of bot-to-bot messages in a row (default 3, restarted by a person's message).
|
|
205
|
+
It is off by default, and the host's list alone decides who; the peer's text is as untrusted as a guest's.
|
|
206
|
+
The access rules still have to serve the peer's identity (`discord:<id>`).
|
|
207
|
+
|
|
188
208
|
### Channel context for guests
|
|
189
209
|
|
|
190
210
|
`channelContext: true` (or the Discord plugin's `ChannelContextOptions`) is off by default.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
4
4
|
"description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
39
|
-
"pi-roundtable": ">=0.
|
|
39
|
+
"pi-roundtable": ">=0.27.0 <0.28.0",
|
|
40
40
|
"typebox": ">=1.3.34 <2"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@biomejs/biome": "2.5.15",
|
|
53
53
|
"@types/bun": "1.4.3",
|
|
54
54
|
"discord.js": "14.27.0",
|
|
55
|
-
"pi-roundtable": "0.
|
|
55
|
+
"pi-roundtable": "0.27.1",
|
|
56
56
|
"typebox": "1.3.37",
|
|
57
57
|
"typescript": "7.0.2"
|
|
58
58
|
}
|
package/src/claim.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
InboundMessage,
|
|
5
5
|
SurfacePort,
|
|
6
6
|
} from "pi-roundtable";
|
|
7
|
-
import { splitReply } from "pi-roundtable/kit";
|
|
7
|
+
import { answersAuthor, splitReply } from "pi-roundtable/kit";
|
|
8
8
|
import {
|
|
9
9
|
type ReadChannelContext,
|
|
10
10
|
readsChannelContext,
|
|
@@ -23,6 +23,16 @@ export type SandboxReplyText = (
|
|
|
23
23
|
context: { channel: ChannelKey; space?: string },
|
|
24
24
|
) => string | Promise<string>;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Draws a PNG card for a successful reply, such as a picture that goes with its text. It runs on
|
|
28
|
+
* the host after `replyText`, on the text that is posted, and the surface posts the card before
|
|
29
|
+
* the text. Return undefined for no card; a throw or a rejection posts the reply without one.
|
|
30
|
+
*/
|
|
31
|
+
export type SandboxReplyCard = (
|
|
32
|
+
text: string,
|
|
33
|
+
context: { channel: ChannelKey; space?: string },
|
|
34
|
+
) => Uint8Array | undefined | Promise<Uint8Array | undefined>;
|
|
35
|
+
|
|
26
36
|
export interface SandboxClaimOptions {
|
|
27
37
|
/** `owns` adds channels in configured guilds; without it only `has` decides. */
|
|
28
38
|
channels: Pick<SandboxChannelStore, "has"> &
|
|
@@ -32,6 +42,15 @@ export interface SandboxClaimOptions {
|
|
|
32
42
|
/** Told of a failed turn, with what failed (a sandbox error, or replyText's own throw). */
|
|
33
43
|
reportFailure?: (channel: ChannelKey, error: unknown) => void;
|
|
34
44
|
replyText?: SandboxReplyText;
|
|
45
|
+
replyCard?: SandboxReplyCard;
|
|
46
|
+
/** Told when `replyCard` threw; the reply is then posted without a card. */
|
|
47
|
+
reportCardFailure?: (channel: ChannelKey, error: unknown) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Answers the bots the host lists as peers (`discord.peerBots`) when they mention the bot or
|
|
50
|
+
* reply to it, as it answers a person; default false, so a sandbox channel stays closed to bots
|
|
51
|
+
* even when the host answers peers elsewhere.
|
|
52
|
+
*/
|
|
53
|
+
peerBots?: boolean;
|
|
35
54
|
/**
|
|
36
55
|
* Reads the context of the channel a message was posted in; set to opt in. It is called only for
|
|
37
56
|
* a Discord server message, with that message, and what it returns goes before the message in
|
|
@@ -42,9 +61,12 @@ export interface SandboxClaimOptions {
|
|
|
42
61
|
reportContextFailure?: (channel: ChannelKey, error: unknown) => void;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
export function isSandboxAddress(
|
|
64
|
+
export function isSandboxAddress(
|
|
65
|
+
message: InboundMessage,
|
|
66
|
+
peerBots = false,
|
|
67
|
+
): boolean {
|
|
46
68
|
return (
|
|
47
|
-
!message.authorIsBot &&
|
|
69
|
+
(peerBots ? answersAuthor(message) : !message.authorIsBot) &&
|
|
48
70
|
!message.integration &&
|
|
49
71
|
!message.isDirect &&
|
|
50
72
|
(message.mentionsBot || message.repliesToBot)
|
|
@@ -84,6 +106,24 @@ async function rewriteReply(
|
|
|
84
106
|
return rewritten;
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
/** The card for a posted reply; none, reported, when `replyCard` throws. */
|
|
110
|
+
async function cardFor(
|
|
111
|
+
options: Pick<SandboxClaimOptions, "replyCard" | "reportCardFailure">,
|
|
112
|
+
text: string,
|
|
113
|
+
message: InboundMessage,
|
|
114
|
+
): Promise<Uint8Array | undefined> {
|
|
115
|
+
if (!options.replyCard) return undefined;
|
|
116
|
+
try {
|
|
117
|
+
return await options.replyCard(text, {
|
|
118
|
+
channel: message.channel,
|
|
119
|
+
...(message.space ? { space: message.space } : {}),
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
options.reportCardFailure?.(message.channel, error);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
87
127
|
/** Outranks the agent server's priority 100; ignored messages never fall through to host agents. */
|
|
88
128
|
export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
89
129
|
const owns = (channel: ChannelKey, space?: string): boolean =>
|
|
@@ -102,7 +142,8 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
|
102
142
|
postsInPlace: true,
|
|
103
143
|
owns,
|
|
104
144
|
admit: (message) => {
|
|
105
|
-
if (!admits(message) || !isSandboxAddress(message))
|
|
145
|
+
if (!admits(message) || !isSandboxAddress(message, options.peerBots))
|
|
146
|
+
return undefined;
|
|
106
147
|
// The router resolved the author; one the host's access rules serve no one by goes
|
|
107
148
|
// unanswered, as does a message whose author it could not resolve.
|
|
108
149
|
const { speaker } = message;
|
|
@@ -133,8 +174,12 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
|
133
174
|
const posted = reply.ok
|
|
134
175
|
? await rewriteReply(options.replyText, reply.text, message)
|
|
135
176
|
: "The sandbox turn failed.";
|
|
177
|
+
const card = reply.ok
|
|
178
|
+
? await cardFor(options, posted, message)
|
|
179
|
+
: undefined;
|
|
136
180
|
await options.surfaces.sendReply(message.channel, {
|
|
137
181
|
chunks: splitReply(posted),
|
|
182
|
+
...(card ? { card } : {}),
|
|
138
183
|
});
|
|
139
184
|
} catch (error) {
|
|
140
185
|
// A stop or a full queue is the runtime working as designed, not a failure.
|
package/src/index.ts
CHANGED
package/src/plugin.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
type SandboxChannelContext,
|
|
8
8
|
} from "./channel-context.ts";
|
|
9
9
|
import { SandboxChannelStore } from "./channel-store.ts";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type SandboxReplyCard,
|
|
12
|
+
type SandboxReplyText,
|
|
13
|
+
sandboxClaim,
|
|
14
|
+
} from "./claim.ts";
|
|
11
15
|
import { sandboxCommands } from "./commands.ts";
|
|
12
16
|
import { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
|
|
13
17
|
|
|
@@ -33,6 +37,19 @@ export interface SandboxOptions
|
|
|
33
37
|
* fails the turn.
|
|
34
38
|
*/
|
|
35
39
|
replyText?: SandboxReplyText;
|
|
40
|
+
/**
|
|
41
|
+
* Draws a PNG card for each successful reply on the host, after `replyText`, from the text that
|
|
42
|
+
* is posted; the card is posted before the text. Return undefined for none. A throw or a
|
|
43
|
+
* rejection is logged and the reply is posted without a card. Failure texts get no card.
|
|
44
|
+
*/
|
|
45
|
+
replyCard?: SandboxReplyCard;
|
|
46
|
+
/**
|
|
47
|
+
* Off by default. On, the bots the host lists under `discord.peerBots` are answered like a
|
|
48
|
+
* person when they mention the bot or reply to it, within the host's round cap; any other bot
|
|
49
|
+
* is still ignored. The host's allowlist decides who; this only opens the sandbox channels to
|
|
50
|
+
* them, and the peer's text is as untrusted as a guest's.
|
|
51
|
+
*/
|
|
52
|
+
peerBots?: boolean;
|
|
36
53
|
/**
|
|
37
54
|
* Off by default. On, a turn in a Discord server channel starts with what others said in that
|
|
38
55
|
* channel since the assistant last posted, read through the Discord plugin's `channelContext`
|
|
@@ -100,6 +117,17 @@ export function sandbox(options: SandboxOptions) {
|
|
|
100
117
|
runtime,
|
|
101
118
|
surfaces: context.surfaces,
|
|
102
119
|
...(options.replyText ? { replyText: options.replyText } : {}),
|
|
120
|
+
...(options.replyCard
|
|
121
|
+
? {
|
|
122
|
+
replyCard: options.replyCard,
|
|
123
|
+
reportCardFailure: (channel: ChannelKey, error: unknown) =>
|
|
124
|
+
context.logger.warn(
|
|
125
|
+
{ channel, err: error },
|
|
126
|
+
"sandbox replyCard failed; the reply is posted without a card",
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
...(options.peerBots ? { peerBots: true } : {}),
|
|
103
131
|
...(contextOptions && discord
|
|
104
132
|
? {
|
|
105
133
|
channelContext: (message) =>
|