pi-roundtable 0.12.0 → 0.12.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 +7 -0
- package/docs/plugins.md +2 -1
- package/package.json +1 -1
- package/src/core/attachments/attachment-fetcher.ts +22 -4
- package/src/core/attachments/turn-attachments.ts +19 -1
- package/src/core/builtin/discord.ts +5 -1
- package/src/core/discord/card-copies.ts +35 -4
- package/src/core/discord/owner-cards.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.12.1] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Expired card copy texts leave `card-copies.json`, not only memory. They are dropped from the file when the host starts, when a copy button finds one past `keepDays`, and on an hourly sweep (an unref'd timer that the Discord plugin starts and stops with the host), without waiting for a new card.
|
|
13
|
+
- Downloading the files of an owner or agent channel message has an overall 20 second deadline for the message and its reference together, and takes an `AbortSignal`: `attachmentsOf(message, dir, logger, fetchImpl?, limits?)` gains `limits: { deadlineMs?: number; signal?: AbortSignal }`. Files still to come when it passes, a body that stalls, and a directory that cannot be created are recorded as `download failed` failures instead of holding the turn or throwing.
|
|
14
|
+
|
|
8
15
|
## [0.12.0] - 2026-10-13
|
|
9
16
|
|
|
10
17
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -553,6 +553,7 @@ A key the contract does not have stops the start and names the closest one.
|
|
|
553
553
|
### `context.attachments`: files from outside a turn
|
|
554
554
|
|
|
555
555
|
A surface that takes a file as it arrives, such as Discord, hands the core a link, and the core downloads it into the conversation's attachment directory.
|
|
556
|
+
The downloads of one message and of the message it replies to share a 20 second deadline; a file still to come when it passes, or whose directory cannot be made, is reported as `download failed` and the turn goes on without it.
|
|
556
557
|
A plugin whose clients upload a file themselves, over HTTP for instance, has the file before any turn exists.
|
|
557
558
|
`context.attachments` keeps such a file for the conversation until a turn uses it:
|
|
558
559
|
|
|
@@ -2563,7 +2564,7 @@ A surface or a `Prompts` of your own may ignore it: nothing requires reading it.
|
|
|
2563
2564
|
|
|
2564
2565
|
`details.copy` (`{ label?, text }`) is a text the approval offers to copy, such as a command or a message the call would send; whoever builds `details` for a `confirm` of their own sets it.
|
|
2565
2566
|
It is informational and never an answer: using it does not approve, decline or otherwise settle the prompt, and a surface that cannot show it ignores it, as the web chat's own client does (the frame carries it as `approval.copy`, left out when it would not fit beside the call's `input` in the frame's 256 KiB: the input is never cut for it).
|
|
2566
|
-
On Discord the card gets a third button (`label`, default "Copy", or its Traditional Chinese equivalent on a host in that language) that any member who can see the card may press, any number of times, also after the card has ended; it replies ephemerally with the text in a code block, or as a `copy.txt` file when the text does not fit a message (2,000 characters) or holds a code fence. The button's custom id holds the card's id, never the text: the text is kept under that id in `card-copies.json` in the host's `dataDir`, in plain text and readable by the host's user only, so the button works after a restart even though the card itself no longer answers; a card whose text is gone says so. `discord.cardCopies` (`{ keepDays?, keep? }`, default the newest 100 cards for 30 days) sets how long and how many; `false` keeps the texts in memory only, with the same limits, so they are gone at a restart. The file is written in the background, one write at a time, so a card is not held up by it. The attachments' `retention` and discarding a conversation do not remove the texts: they age out, or set `cardCopies` to `false` on a host that wants none on disk: the host then deletes an existing `card-copies.json` when it starts. A text over 20,000 characters gets no button, and a warning is logged.
|
|
2567
|
+
On Discord the card gets a third button (`label`, default "Copy", or its Traditional Chinese equivalent on a host in that language) that any member who can see the card may press, any number of times, also after the card has ended; it replies ephemerally with the text in a code block, or as a `copy.txt` file when the text does not fit a message (2,000 characters) or holds a code fence. The button's custom id holds the card's id, never the text: the text is kept under that id in `card-copies.json` in the host's `dataDir`, in plain text and readable by the host's user only, so the button works after a restart even though the card itself no longer answers; a card whose text is gone says so. `discord.cardCopies` (`{ keepDays?, keep? }`, default the newest 100 cards for 30 days) sets how long and how many; `false` keeps the texts in memory only, with the same limits, so they are gone at a restart. The file is written in the background, one write at a time, so a card is not held up by it. A text past `keepDays` leaves the file too, not only memory: when the host starts or a button finds one expired, and on an hourly sweep that does not keep the process alive. The attachments' `retention` and discarding a conversation do not remove the texts: they age out, or set `cardCopies` to `false` on a host that wants none on disk: the host then deletes an existing `card-copies.json` when it starts. A text over 20,000 characters gets no button, and a warning is logged.
|
|
2567
2568
|
Because anyone who can see the card can copy the text, it holds nothing they may not read.
|
|
2568
2569
|
`confirm` resolves `approved`, `declined`, `expired` (a card was shown and went unanswered), `unavailable` (no card could be shown: no one may approve the call, the speaker is below its tier, or the card could not be posted), `cancelled` (the turn stopped), or `pending` (with a `wait`). Both `expired` and `unavailable` leave the call held for the owner's next message; the turn's progress reports them as `refused: "expired"` and `refused: "held"`.
|
|
2569
2570
|
|
package/package.json
CHANGED
|
@@ -16,6 +16,8 @@ export interface FetchRequest {
|
|
|
16
16
|
/** Prefix that keeps files of different messages apart, such as the message ID. */
|
|
17
17
|
prefix: string;
|
|
18
18
|
fromReference: boolean;
|
|
19
|
+
/** Aborts the download of the files still to come, and the one in flight. */
|
|
20
|
+
signal?: AbortSignal;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface FetchResult {
|
|
@@ -63,25 +65,33 @@ export async function fetchAttachments(
|
|
|
63
65
|
): Promise<FetchResult> {
|
|
64
66
|
const files: StoredAttachment[] = [];
|
|
65
67
|
const failures: AttachmentFailure[] = [];
|
|
66
|
-
const { refs, dir, prefix, fromReference } = request;
|
|
68
|
+
const { refs, dir, prefix, fromReference, signal } = request;
|
|
67
69
|
if (refs.length === 0) return { files, failures };
|
|
68
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
69
70
|
|
|
70
71
|
for (const [index, ref] of refs.entries()) {
|
|
71
72
|
const fail = (reason: string) =>
|
|
72
73
|
failures.push({ name: ref.name, reason, fromReference });
|
|
74
|
+
if (signal?.aborted) {
|
|
75
|
+
fail("download failed: deadline passed or download aborted");
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
73
78
|
if (ref.size > MAX_ATTACHMENT_BYTES) {
|
|
74
79
|
fail(`larger than 25 MB (${ref.size} bytes)`);
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
82
|
try {
|
|
78
|
-
|
|
83
|
+
// A directory that cannot be made fails the file, not the turn.
|
|
84
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
const response = await fetchImpl(
|
|
86
|
+
ref.url,
|
|
87
|
+
signal ? { signal } : undefined,
|
|
88
|
+
);
|
|
79
89
|
if (!response.ok) {
|
|
80
90
|
await response.body?.cancel();
|
|
81
91
|
fail(`download failed with HTTP ${response.status}`);
|
|
82
92
|
continue;
|
|
83
93
|
}
|
|
84
|
-
const data = await readAttachmentBody(response);
|
|
94
|
+
const data = await readAttachmentBody(response, signal);
|
|
85
95
|
if (typeof data === "number") {
|
|
86
96
|
fail(`larger than 25 MB (${data} bytes)`);
|
|
87
97
|
continue;
|
|
@@ -110,12 +120,17 @@ export async function fetchAttachments(
|
|
|
110
120
|
/** Returns the bytes, or the observed size once the stream exceeds the limit. */
|
|
111
121
|
async function readAttachmentBody(
|
|
112
122
|
response: Response,
|
|
123
|
+
signal?: AbortSignal,
|
|
113
124
|
): Promise<Uint8Array | number> {
|
|
114
125
|
if (!response.body) return new Uint8Array();
|
|
115
126
|
const reader = response.body.getReader();
|
|
116
127
|
const chunks: Uint8Array[] = [];
|
|
117
128
|
let size = 0;
|
|
129
|
+
// A body that stalls ends with the signal, also when the fetch itself ignores it.
|
|
130
|
+
const stop = () => void reader.cancel(signal?.reason).catch(() => {});
|
|
131
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
118
132
|
try {
|
|
133
|
+
if (signal?.aborted) throw signal.reason ?? new Error("aborted");
|
|
119
134
|
for (;;) {
|
|
120
135
|
const { done, value } = await reader.read();
|
|
121
136
|
if (done) break;
|
|
@@ -126,7 +141,10 @@ async function readAttachmentBody(
|
|
|
126
141
|
}
|
|
127
142
|
chunks.push(value);
|
|
128
143
|
}
|
|
144
|
+
// A cancelled reader reads as the end of the body, so a cut-short body is not a whole file.
|
|
145
|
+
if (signal?.aborted) throw signal.reason ?? new Error("aborted");
|
|
129
146
|
} finally {
|
|
147
|
+
signal?.removeEventListener("abort", stop);
|
|
130
148
|
reader.releaseLock();
|
|
131
149
|
}
|
|
132
150
|
const data = new Uint8Array(size);
|
|
@@ -4,19 +4,34 @@ import type { Logger } from "../log.ts";
|
|
|
4
4
|
import { fetchAttachments } from "./attachment-fetcher.ts";
|
|
5
5
|
import { modelImagesOf } from "./model-images.ts";
|
|
6
6
|
|
|
7
|
+
/** The most milliseconds all of a message's downloads take together by default. */
|
|
8
|
+
const DEADLINE_MS = 20_000;
|
|
9
|
+
|
|
7
10
|
/** Saves the message's and its reference's files, and turns up to four images into model images. */
|
|
8
11
|
export async function collectAttachments(
|
|
9
12
|
message: InboundMessage,
|
|
10
13
|
dir: string,
|
|
11
|
-
options: {
|
|
14
|
+
options: {
|
|
15
|
+
logger: Logger;
|
|
16
|
+
fetchImpl?: typeof fetch;
|
|
17
|
+
/** One deadline for the message's and its reference's files together; default 20 s. */
|
|
18
|
+
deadlineMs?: number;
|
|
19
|
+
/** Aborts the downloads, as the deadline does. */
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
},
|
|
12
22
|
): Promise<TurnAttachments> {
|
|
13
23
|
const { fetchImpl, logger } = options;
|
|
24
|
+
const deadline = AbortSignal.timeout(options.deadlineMs ?? DEADLINE_MS);
|
|
25
|
+
const signal = options.signal
|
|
26
|
+
? AbortSignal.any([deadline, options.signal])
|
|
27
|
+
: deadline;
|
|
14
28
|
const own = await fetchAttachments(
|
|
15
29
|
{
|
|
16
30
|
refs: message.attachments,
|
|
17
31
|
dir,
|
|
18
32
|
prefix: message.messageId,
|
|
19
33
|
fromReference: false,
|
|
34
|
+
signal,
|
|
20
35
|
},
|
|
21
36
|
fetchImpl,
|
|
22
37
|
);
|
|
@@ -26,6 +41,7 @@ export async function collectAttachments(
|
|
|
26
41
|
dir,
|
|
27
42
|
prefix: `${message.messageId}-ref`,
|
|
28
43
|
fromReference: true,
|
|
44
|
+
signal,
|
|
29
45
|
},
|
|
30
46
|
fetchImpl,
|
|
31
47
|
);
|
|
@@ -54,9 +70,11 @@ export function attachmentsOf(
|
|
|
54
70
|
dir: string,
|
|
55
71
|
logger: Logger,
|
|
56
72
|
fetchImpl?: typeof fetch,
|
|
73
|
+
limits: { deadlineMs?: number; signal?: AbortSignal } = {},
|
|
57
74
|
): Promise<TurnAttachments> {
|
|
58
75
|
return collectAttachments(message, dir, {
|
|
59
76
|
logger,
|
|
77
|
+
...limits,
|
|
60
78
|
...(fetchImpl ? { fetchImpl } : {}),
|
|
61
79
|
});
|
|
62
80
|
}
|
|
@@ -202,7 +202,11 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
|
|
|
202
202
|
// The sweep reads threads through the connection, so it follows the surface's start.
|
|
203
203
|
{ name: "threads", start: () => void threads.sweep() },
|
|
204
204
|
// The copy texts of the last cards are on disk before the host exits.
|
|
205
|
-
{
|
|
205
|
+
{
|
|
206
|
+
name: "card-copies",
|
|
207
|
+
start: () => cards.startCopies(),
|
|
208
|
+
stop: () => cards.stopCopies(),
|
|
209
|
+
},
|
|
206
210
|
],
|
|
207
211
|
};
|
|
208
212
|
},
|
|
@@ -11,6 +11,8 @@ export const COPY_TEXT_LIMIT = 20_000;
|
|
|
11
11
|
const COPY_KEEP = 100;
|
|
12
12
|
/** How many days a copy text is kept by default. */
|
|
13
13
|
const COPY_KEEP_DAYS = 30;
|
|
14
|
+
/** How often expired texts are dropped from the file, an hour. */
|
|
15
|
+
const SWEEP_MS = 60 * 60_000;
|
|
14
16
|
|
|
15
17
|
interface Entry {
|
|
16
18
|
text: string;
|
|
@@ -27,6 +29,12 @@ export interface CardCopiesOptions {
|
|
|
27
29
|
logger: Logger;
|
|
28
30
|
/** Replaceable in tests. */
|
|
29
31
|
now?: () => number;
|
|
32
|
+
/** How often expired texts are dropped from the file; default `SWEEP_MS`. */
|
|
33
|
+
sweepMs?: number;
|
|
34
|
+
/** Replaceable in tests. */
|
|
35
|
+
setInterval?: typeof setInterval;
|
|
36
|
+
/** Replaceable in tests, with `setInterval`. */
|
|
37
|
+
clearInterval?: typeof clearInterval;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
/**
|
|
@@ -42,6 +50,7 @@ export class CardCopies {
|
|
|
42
50
|
readonly #entries = new Map<string, Entry>();
|
|
43
51
|
#writing: Promise<void> | undefined;
|
|
44
52
|
#dirty = false;
|
|
53
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
45
54
|
|
|
46
55
|
constructor(options: CardCopiesOptions) {
|
|
47
56
|
this.#options = options;
|
|
@@ -56,7 +65,7 @@ export class CardCopies {
|
|
|
56
65
|
if (typeof text === "string" && typeof at === "number")
|
|
57
66
|
this.#entries.set(id, { text, at });
|
|
58
67
|
}
|
|
59
|
-
this.#prune();
|
|
68
|
+
if (this.#prune()) this.#schedule();
|
|
60
69
|
} catch (error) {
|
|
61
70
|
logger.warn(
|
|
62
71
|
{ path, err: error },
|
|
@@ -77,12 +86,31 @@ export class CardCopies {
|
|
|
77
86
|
const entry = this.#entries.get(id);
|
|
78
87
|
if (!entry) return undefined;
|
|
79
88
|
if (this.#now() - entry.at > this.#keepMs()) {
|
|
80
|
-
this.#
|
|
89
|
+
this.#prune();
|
|
90
|
+
this.#schedule();
|
|
81
91
|
return undefined;
|
|
82
92
|
}
|
|
83
93
|
return entry.text;
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
/** Starts the hourly sweep that drops expired texts from the file too; the timer does not hold the process. */
|
|
97
|
+
start(): void {
|
|
98
|
+
if (this.#timer || !this.#options.path) return;
|
|
99
|
+
const every = this.#options.setInterval ?? setInterval;
|
|
100
|
+
this.#timer = every(() => {
|
|
101
|
+
if (this.#prune()) this.#schedule();
|
|
102
|
+
}, this.#options.sweepMs ?? SWEEP_MS);
|
|
103
|
+
this.#timer.unref?.();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stops the sweep and resolves once what was remembered so far is on disk; for a host that stops and for tests. */
|
|
107
|
+
async stop(): Promise<void> {
|
|
108
|
+
if (this.#timer)
|
|
109
|
+
(this.#options.clearInterval ?? clearInterval)(this.#timer);
|
|
110
|
+
this.#timer = undefined;
|
|
111
|
+
await this.flush();
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/** Resolves once what was remembered so far is on disk; for a host that stops and for tests. */
|
|
87
115
|
async flush(): Promise<void> {
|
|
88
116
|
while (this.#writing) await this.#writing;
|
|
@@ -96,15 +124,18 @@ export class CardCopies {
|
|
|
96
124
|
return (this.#options.keepDays ?? COPY_KEEP_DAYS) * 24 * 60 * 60_000;
|
|
97
125
|
}
|
|
98
126
|
|
|
99
|
-
|
|
127
|
+
/** Drops expired and surplus entries; true when any went. */
|
|
128
|
+
#prune(): boolean {
|
|
129
|
+
const before = this.#entries.size;
|
|
100
130
|
const now = this.#now();
|
|
101
131
|
const keep = this.#options.keep ?? COPY_KEEP;
|
|
102
132
|
for (const [id, entry] of this.#entries)
|
|
103
133
|
if (now - entry.at > this.#keepMs()) this.#entries.delete(id);
|
|
104
134
|
for (const id of this.#entries.keys()) {
|
|
105
|
-
if (this.#entries.size <= keep)
|
|
135
|
+
if (this.#entries.size <= keep) break;
|
|
106
136
|
this.#entries.delete(id);
|
|
107
137
|
}
|
|
138
|
+
return this.#entries.size < before;
|
|
108
139
|
}
|
|
109
140
|
|
|
110
141
|
/** Writes the file after the call that asked, once at a time; asks made meanwhile share the next write. */
|
|
@@ -111,6 +111,8 @@ export class OwnerCards implements InteractionModule {
|
|
|
111
111
|
}
|
|
112
112
|
/** Resolves once the remembered copy texts are on disk. */
|
|
113
113
|
flushCopies = (): Promise<void> => this.#copies.flush();
|
|
114
|
+
startCopies = (): void => this.#copies.start();
|
|
115
|
+
stopCopies = (): Promise<void> => this.#copies.stop();
|
|
114
116
|
|
|
115
117
|
commands() {
|
|
116
118
|
return [];
|