pi-jar 0.1.8 → 0.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/src/democracy.ts CHANGED
@@ -25,7 +25,8 @@ export interface DemocracyResult {
25
25
  shortlist: string[];
26
26
  winner?: string;
27
27
  }
28
- const live = (agent: SubagentReport) => ["queued", "working", "idle", "paused"].includes(agent.state);
28
+ /** Retained voters hold pool slots whether or not their process is currently running. */
29
+ const live = (agent: SubagentReport) => ["queued", "working", "idle", "paused", "hibernated"].includes(agent.state);
29
30
  /** One round never outlives this; late ballots count as failures, never as guesses. */
30
31
  export const BALLOT_TIMEOUT_MS = 10 * 60_000;
31
32
  /** Rationale and failure text returned to the moderator per voter. */
@@ -53,7 +54,8 @@ export function validateDemocracy(request: DemocracyRequest): void {
53
54
  }
54
55
 
55
56
  export function parseBallot(report: SubagentReport, nonce: string, options: readonly DecisionOption[]): Ballot {
56
- if (report.error || report.state !== "idle") throw new Error(report.error ?? "Scout did not finish its ballot");
57
+ // A finished ballot turn is idle, or already hibernated if its process closed before the tally.
58
+ if (report.error || (report.state !== "idle" && report.state !== "hibernated")) throw new Error(report.error ?? "Scout did not finish its ballot");
57
59
  const lines = report.output.split(/\r?\n/).filter((line) => line.startsWith("JAR_BALLOT "));
58
60
  if (lines.length !== 1) throw new Error("Expected exactly one private JAR_BALLOT response");
59
61
  const raw = JSON.parse(lines[0]!.slice(11)) as { round?: unknown; option?: unknown; rationale?: unknown };
@@ -95,8 +97,8 @@ export async function conductDemocracy(controller: DelegateController, request:
95
97
  const fleet = controller.list();
96
98
  for (const id of agents) {
97
99
  const agent = fleet.find((item) => item.id === id);
98
- if (!agent || agent.mode !== "scout" || !["idle", "paused"].includes(agent.state)) {
99
- throw new Error("Only idle/paused fresh-context read-only scouts can be resumed for voting: " + id);
100
+ if (!agent || agent.mode !== "scout" || !["idle", "paused", "hibernated"].includes(agent.state)) {
101
+ throw new Error("Only idle/paused/hibernated fresh-context read-only scouts can be resumed for voting: " + id);
100
102
  }
101
103
  }
102
104
  const newScouts = voters - agents.length;
@@ -0,0 +1,418 @@
1
+ import { cleanText } from "./status.ts";
2
+
3
+ /** The moderator's fixed identity; subagents are registered under their delegate registry key. */
4
+ export const MODERATOR = "moderator";
5
+ export const MAX_DISCUSSION_MESSAGES = 128;
6
+ export const MAX_DISCUSSION_TEXT = 1600;
7
+ export const MAX_DISCUSSION_BYTES = 128 * 1024;
8
+ /** Questions stop this far below the hard bounds, so there is always room to answer: answering is the
9
+ * progress a capacity error demands, and it must never be blocked by the questions it would resolve. */
10
+ const ANSWER_RESERVE_MESSAGES = 16;
11
+ const ANSWER_RESERVE_BYTES = 16 * 1024;
12
+ /** The delegate registry bounds live agents; this only guards against a missed retire. */
13
+ const MAX_DISCUSSION_AGENTS = 64;
14
+ /** A list reply never exceeds this, however many messages match; each listed message is clipped too. */
15
+ export const MAX_LIST_CHARS = 4000;
16
+ export const MAX_LISTED_TEXT = 400;
17
+ /** Unread mail piggybacked on an ask/answer reply never exceeds this. */
18
+ const MAX_PIGGYBACK_CHARS = 1000;
19
+ const BROADCAST: Record<string, true> = { all: true, "*": true, everyone: true, broadcast: true };
20
+
21
+ /** A rejected discussion operation; its message is terse, safe to show the model, and never echoes text. */
22
+ export class DiscussionError extends Error {}
23
+
24
+ /** A message as one agent is shown it: identities are display labels, text may be clipped. */
25
+ export interface MessageView { id: number; kind: "question" | "answer"; from: string; to?: string; replyTo?: number; text: string }
26
+ /** `delivered` is unread mail piggybacked on the reply (and now read); `unread` is what is still unread. */
27
+ export interface PostResult { id: number; to?: string; replyTo?: number; clipped: boolean; delivered: MessageView[]; unread: number }
28
+ export interface ListResult { messages: MessageView[]; more: number; latest?: number; unread: number }
29
+ export interface ThreadResult { id: number; missing: boolean; question?: MessageView; answers: MessageView[]; more: number }
30
+ export interface PendingResult {
31
+ unread: number;
32
+ /** Unanswered questions addressed to the caller (directly or by broadcast). */
33
+ waiting: number[];
34
+ /** The caller's own unanswered questions. */
35
+ open: number[];
36
+ messages: number;
37
+ bytes: number;
38
+ /** Moderator only: who has unread mail, and the oldest unanswered questions. No bodies. */
39
+ agents?: Array<{ label: string; unread: number }>;
40
+ unanswered?: { total: number; questions: Array<{ id: number; from: string; to?: string }> };
41
+ }
42
+ export interface UnreadResult { unread: number; newest?: number }
43
+ export interface HubStats { agents: number; messages: number; bytes: number; unanswered: number; unread: number; oldestId?: number; newestId?: number }
44
+
45
+ /** `cursor` covers every id at or below it; `seen` holds reads above it (thread, since), never more than retained ids. */
46
+ interface Agent { key: string; name: string; cursor: number; seen: Set<number> }
47
+ interface Stored {
48
+ id: number;
49
+ kind: "question" | "answer";
50
+ from: string;
51
+ fromName: string;
52
+ text: string;
53
+ bytes: number;
54
+ /** Targeted question; absent means broadcast. */
55
+ to?: string;
56
+ toName?: string;
57
+ /** Question: who answered it. Outlives evicted answers, so an answered question never looks open again. */
58
+ answerers?: Set<string>;
59
+ replyTo?: number;
60
+ /** Answer: routes to the asker even after its question is evicted. */
61
+ asker?: string;
62
+ }
63
+
64
+ const PENDING_LIMIT = 16;
65
+ const kib = (bytes: number) => (bytes / 1024).toFixed(1);
66
+ const utf8 = (value: string | undefined) => value ? Buffer.byteLength(value, "utf8") : 0;
67
+ /** Encoded size: UTF-8 text and identities plus a fixed allowance for id/kind/reference framing. */
68
+ const encodedSize = (message: Stored) => 64 + utf8(message.text) + utf8(message.from) + utf8(message.fromName)
69
+ + utf8(message.to) + utf8(message.toName) + utf8(message.asker);
70
+
71
+ /** Accepts 12, "12", "d12" and "#d12"; ids render as d12. */
72
+ export function parseDiscussionId(value: unknown): number | undefined {
73
+ if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
74
+ const match = typeof value === "string" ? /^\s*#?d?(\d{1,15})\s*$/i.exec(value) : null;
75
+ return match ? Number(match[1]) : undefined;
76
+ }
77
+
78
+ export function messageLine(view: MessageView): string {
79
+ return view.kind === "question"
80
+ ? `[d${view.id}] Q · ${view.from}${view.to ? " → " + view.to : ""}: ${view.text}`
81
+ : `[d${view.id}] A · ${view.from} → d${view.replyTo}: ${view.text}`;
82
+ }
83
+
84
+ /**
85
+ * Session-local discussion state, owned by the parent and touched only synchronously, so ordering is
86
+ * the call order: no locks, no files, no retries. Each agent gets the mail relevant to it (questions to
87
+ * it, broadcasts, answers to its questions; never its own) through a per-agent read cursor.
88
+ */
89
+ export class DiscussionHub {
90
+ private readonly agents = new Map<string, Agent>();
91
+ /** Lower-cased live agent names → count, so labels know when a name alone is ambiguous. */
92
+ private readonly names = new Map<string, number>();
93
+ private readonly byId = new Map<number, Stored>();
94
+ private messages: Stored[] = [];
95
+ private lastId = 0;
96
+ private size = 0;
97
+ /** Bumped on every state change, so an owner repaints only when something moved. */
98
+ revision = 0;
99
+
100
+ constructor() { this.register(MODERATOR, MODERATOR); }
101
+
102
+ has(key: string): boolean { return this.agents.has(key); }
103
+
104
+ /** Idempotent per key. A new agent's cursor starts at the newest id, so it never ingests earlier chatter. */
105
+ register(key: string, name: string): void {
106
+ const label = cleanText(name, 48) || key;
107
+ const agent = this.agents.get(key);
108
+ if (agent) {
109
+ if (agent.name === label) return;
110
+ this.count(agent.name, -1);
111
+ agent.name = label;
112
+ this.count(label, 1);
113
+ this.revision++;
114
+ return;
115
+ }
116
+ if (this.agents.size > MAX_DISCUSSION_AGENTS) throw new DiscussionError("Discussion agent limit reached.");
117
+ this.agents.set(key, { key, name: label, cursor: this.lastId, seen: new Set() });
118
+ this.count(label, 1);
119
+ this.revision++;
120
+ }
121
+
122
+ /** Permanent. The mailbox goes; the agent's messages stay until normal retention evicts them. */
123
+ retire(key: string): void {
124
+ const agent = this.agents.get(key);
125
+ if (!agent || key === MODERATOR) return;
126
+ this.agents.delete(key);
127
+ this.count(agent.name, -1);
128
+ this.revision++;
129
+ }
130
+
131
+ /** Drops every message and subagent. Ids keep counting, so a stale reference never aliases a new message. */
132
+ clear(): void {
133
+ this.agents.clear();
134
+ this.names.clear();
135
+ this.byId.clear();
136
+ this.messages = [];
137
+ this.size = 0;
138
+ this.register(MODERATOR, MODERATOR);
139
+ this.revision++;
140
+ }
141
+
142
+ ask(from: string, text: string, to?: string): PostResult {
143
+ const sender = this.agent(from);
144
+ const body = this.body(text);
145
+ const target = this.target(sender, to);
146
+ const message: Stored = {
147
+ id: this.lastId + 1, kind: "question", from: sender.key, fromName: sender.name, text: body.text, bytes: 0, answerers: new Set(),
148
+ ...(target ? { to: target.key, toName: target.name } : {})
149
+ };
150
+ this.append(message);
151
+ return { id: message.id, ...(target ? { to: this.label(target.key, target.name) } : {}), clipped: body.clipped, ...this.deliver(sender) };
152
+ }
153
+
154
+ answer(from: string, questionId: number, text: string): PostResult {
155
+ const sender = this.agent(from);
156
+ const body = this.body(text);
157
+ const question = this.byId.get(questionId);
158
+ if (question?.kind === "answer") throw new DiscussionError(`d${questionId} is an answer; answer its question d${question.replyTo}.`);
159
+ if (!question) throw new DiscussionError(`Unknown discussion question d${questionId}.`);
160
+ const message: Stored = {
161
+ id: this.lastId + 1, kind: "answer", from: sender.key, fromName: sender.name, text: body.text, bytes: 0,
162
+ replyTo: question.id, asker: question.from
163
+ };
164
+ this.append(message, question.id);
165
+ question.answerers!.add(sender.key);
166
+ return { id: message.id, replyTo: question.id, clipped: body.clipped, ...this.deliver(sender) };
167
+ }
168
+
169
+ /** Unread mail, oldest first within the reply budget; the cursor moves past what was shown, so paging
170
+ * loses nothing. `since` rereads relevant messages newer than an id, read or not. */
171
+ list(key: string, since?: number): ListResult {
172
+ const agent = this.agent(key);
173
+ const items = this.messages.filter((message) => this.relevant(agent, message)
174
+ && (since === undefined ? !this.read(agent, message) : message.id > since));
175
+ const { views, shown } = this.select(items, MAX_LISTED_TEXT);
176
+ this.markRead(agent, shown);
177
+ const latest = this.messages.at(-1)?.id;
178
+ return { messages: views, more: items.length - views.length, ...(latest ? { latest } : {}), unread: this.unreadOf(agent).length };
179
+ }
180
+
181
+ /** One question and its answers in full, oldest first within the reply budget. Marks only those
182
+ * messages read: unrelated unread mail stays unread. An answer id reads its question's thread. */
183
+ thread(key: string, questionId: number, since?: number): ThreadResult {
184
+ const agent = this.agent(key);
185
+ const named = this.byId.get(questionId);
186
+ const root = named?.kind === "answer" ? named.replyTo! : questionId;
187
+ const question = this.byId.get(root);
188
+ const answers = this.messages.filter((message) => message.replyTo === root);
189
+ if (!question && !answers.length) throw new DiscussionError(`Unknown discussion question d${questionId}.`);
190
+ const items = [...(question ? [question] : []), ...answers].filter((message) => since === undefined || message.id > since);
191
+ const { views, shown } = this.select(items, MAX_DISCUSSION_TEXT);
192
+ this.markRead(agent, shown.filter((message) => this.relevant(agent, message)));
193
+ const head = question && shown[0] === question ? views[0] : undefined;
194
+ return { id: root, missing: !question, ...(head ? { question: head } : {}), answers: head ? views.slice(1) : views, more: items.length - views.length };
195
+ }
196
+
197
+ pending(key: string): PendingResult {
198
+ const agent = this.agent(key);
199
+ const open = this.messages.filter((message) => message.kind === "question" && message.answerers!.size === 0);
200
+ const result: PendingResult = {
201
+ unread: this.unreadOf(agent).length,
202
+ waiting: open.filter((message) => this.relevant(agent, message)).map((message) => message.id),
203
+ open: open.filter((message) => message.from === agent.key).map((message) => message.id),
204
+ messages: this.messages.length,
205
+ bytes: this.size
206
+ };
207
+ if (agent.key !== MODERATOR) return result;
208
+ const agents = [...this.agents.values()].filter((other) => other.key !== MODERATOR)
209
+ .map((other) => ({ label: this.label(other.key, other.name), unread: this.unreadOf(other).length }))
210
+ .filter((entry) => entry.unread > 0).slice(0, PENDING_LIMIT);
211
+ const questions = open.slice(0, PENDING_LIMIT).map((message) => ({
212
+ id: message.id, from: this.label(message.from, message.fromName), ...(message.to ? { to: this.label(message.to, message.toName!) } : {})
213
+ }));
214
+ return { ...result, agents, unanswered: { total: open.length, questions } };
215
+ }
216
+
217
+ unread(key: string): UnreadResult {
218
+ const unread = this.unreadOf(this.agent(key));
219
+ return { unread: unread.length, ...(unread.length ? { newest: unread.at(-1)!.id } : {}) };
220
+ }
221
+
222
+ stats(): HubStats {
223
+ let unread = 0;
224
+ for (const agent of this.agents.values()) unread += this.unreadOf(agent).length;
225
+ const first = this.messages[0];
226
+ const last = this.messages.at(-1);
227
+ return {
228
+ agents: this.agents.size - 1,
229
+ messages: this.messages.length,
230
+ bytes: this.size,
231
+ unanswered: this.messages.filter((message) => message.kind === "question" && message.answerers!.size === 0).length,
232
+ unread,
233
+ ...(first && last ? { oldestId: first.id, newestId: last.id } : {})
234
+ };
235
+ }
236
+
237
+ private agent(key: string): Agent {
238
+ const agent = this.agents.get(key);
239
+ if (!agent) throw new DiscussionError("Unknown discussion agent.");
240
+ return agent;
241
+ }
242
+
243
+ private body(text: string): { text: string; clipped: boolean } {
244
+ const full = cleanText(text, MAX_DISCUSSION_TEXT + 1);
245
+ if (!full) throw new DiscussionError("Discussion text is required.");
246
+ return { text: full.slice(0, MAX_DISCUSSION_TEXT).trimEnd(), clipped: full.length > MAX_DISCUSSION_TEXT };
247
+ }
248
+
249
+ /** A key, `moderator`, or a unique case-insensitive name; empty or `all` broadcasts. */
250
+ private target(sender: Agent, ref: string | undefined): Agent | undefined {
251
+ const value = cleanText(ref ?? "", 64);
252
+ const lower = value.toLowerCase();
253
+ if (!value || Object.hasOwn(BROADCAST, lower)) return undefined;
254
+ let target = this.agents.get(value) ?? this.agents.get(lower);
255
+ if (!target) {
256
+ const matches = [...this.agents.values()].filter((agent) => agent.name.toLowerCase() === lower);
257
+ if (matches.length > 1) throw new DiscussionError(`Ambiguous discussion target; use one of: ${matches.slice(0, 8).map((agent) => agent.key).join(", ")}.`);
258
+ target = matches[0];
259
+ }
260
+ if (!target) throw new DiscussionError(`Unknown discussion target. Known: ${this.known(sender)}; omit to broadcast.`);
261
+ if (target === sender) throw new DiscussionError("A question cannot target its sender.");
262
+ return target;
263
+ }
264
+
265
+ private known(sender: Agent): string {
266
+ const others = [...this.agents.values()].filter((agent) => agent !== sender);
267
+ const shown = others.slice(0, 8).map((agent) => agent.key === MODERATOR ? MODERATOR : `${agent.key} (${agent.name})`);
268
+ return (shown.join(", ") || "none") + (others.length > shown.length ? `, +${others.length - shown.length} more` : "");
269
+ }
270
+
271
+ /** A live agent's name, with its key when the name alone could not address it. Retired agents keep their name. */
272
+ private label(key: string, name: string): string {
273
+ const agent = this.agents.get(key);
274
+ if (!agent || key === MODERATOR) return agent?.name ?? name;
275
+ const lower = agent.name.toLowerCase();
276
+ return (this.names.get(lower) ?? 0) > 1 || lower === MODERATOR || Object.hasOwn(BROADCAST, lower) ? `${agent.name} (${key})` : agent.name;
277
+ }
278
+
279
+ private count(name: string, delta: number): void {
280
+ const lower = name.toLowerCase();
281
+ const next = (this.names.get(lower) ?? 0) + delta;
282
+ if (next > 0) this.names.set(lower, next); else this.names.delete(lower);
283
+ }
284
+
285
+ private relevant(agent: Agent, message: Stored): boolean {
286
+ if (message.from === agent.key) return false;
287
+ if (message.kind === "answer") return message.asker === agent.key;
288
+ return (!message.to || message.to === agent.key) && !message.answerers!.has(agent.key);
289
+ }
290
+
291
+ private read(agent: Agent, message: Stored): boolean { return message.id <= agent.cursor || agent.seen.has(message.id); }
292
+
293
+ private unreadOf(agent: Agent): Stored[] {
294
+ return this.messages.filter((message) => !this.read(agent, message) && this.relevant(agent, message));
295
+ }
296
+
297
+ /** Read by every live agent it is relevant to; retired agents can no longer read anything. */
298
+ private acknowledged(message: Stored): boolean {
299
+ for (const agent of this.agents.values()) if (this.relevant(agent, message) && !this.read(agent, message)) return false;
300
+ return true;
301
+ }
302
+
303
+ private markRead(agent: Agent, messages: readonly Stored[]): void {
304
+ if (!messages.length) return;
305
+ for (const message of messages) if (message.id > agent.cursor) agent.seen.add(message.id);
306
+ this.settle(agent);
307
+ this.revision++;
308
+ }
309
+
310
+ /** Move the cursor up to the first unread relevant message (or the newest id), shrinking `seen`. */
311
+ private settle(agent: Agent): void {
312
+ const blocked = this.messages.find((message) => message.id > agent.cursor && !agent.seen.has(message.id) && this.relevant(agent, message));
313
+ agent.cursor = Math.max(agent.cursor, blocked ? blocked.id - 1 : this.lastId);
314
+ for (const id of agent.seen) if (id <= agent.cursor) agent.seen.delete(id);
315
+ }
316
+
317
+ /** Unread mail rides on an ask/answer reply only when all of it fits; otherwise only its count does. */
318
+ private deliver(agent: Agent): { delivered: MessageView[]; unread: number } {
319
+ const unread = this.unreadOf(agent);
320
+ const views = unread.map((message) => this.view(message, MAX_LISTED_TEXT));
321
+ if (!views.length || views.reduce((size, view) => size + messageLine(view).length + 1, 0) > MAX_PIGGYBACK_CHARS) {
322
+ this.settle(agent);
323
+ return { delivered: [], unread: unread.length };
324
+ }
325
+ this.markRead(agent, unread);
326
+ return { delivered: views, unread: 0 };
327
+ }
328
+
329
+ private view(message: Stored, limit: number): MessageView {
330
+ const text = message.text.length > limit ? message.text.slice(0, limit) + "…" : message.text;
331
+ const from = this.label(message.from, message.fromName);
332
+ if (message.kind === "answer") return { id: message.id, kind: "answer", from, replyTo: message.replyTo!, text };
333
+ return { id: message.id, kind: "question", from, ...(message.to ? { to: this.label(message.to, message.toName!) } : {}), text };
334
+ }
335
+
336
+ /** Oldest first into the budget; at least one message, so paging always progresses. */
337
+ private select(items: readonly Stored[], limit: number): { views: MessageView[]; shown: Stored[] } {
338
+ const views: MessageView[] = [];
339
+ let used = 0;
340
+ for (const message of items) {
341
+ const view = this.view(message, limit);
342
+ const size = messageLine(view).length + 1;
343
+ if (views.length && used + size > MAX_LIST_CHARS) break;
344
+ views.push(view);
345
+ used += size;
346
+ }
347
+ return { views, shown: items.slice(0, views.length) };
348
+ }
349
+
350
+ private append(message: Stored, replyTarget?: number): void {
351
+ message.bytes = encodedSize(message);
352
+ const question = message.kind === "question";
353
+ const evicted = this.eviction(message.bytes,
354
+ question ? MAX_DISCUSSION_MESSAGES - ANSWER_RESERVE_MESSAGES : MAX_DISCUSSION_MESSAGES,
355
+ question ? MAX_DISCUSSION_BYTES - ANSWER_RESERVE_BYTES : MAX_DISCUSSION_BYTES, replyTarget);
356
+ if (!evicted) {
357
+ throw new DiscussionError(`Discussion is full: ${this.messages.length} retained messages (${(this.size / 1024).toFixed(1)} KiB) are unread or awaiting answers. `
358
+ + `Answer or list pending messages (jar_discuss pending) before ${question ? "asking" : "answering"} more.`);
359
+ }
360
+ if (evicted.length) this.remove(new Set(evicted.map((item) => item.id)));
361
+ this.lastId = message.id;
362
+ this.messages.push(message);
363
+ this.byId.set(message.id, message);
364
+ this.size += message.bytes;
365
+ this.revision++;
366
+ }
367
+
368
+ /**
369
+ * What to evict so one more message fits the limits, by priority: answered groups everyone relevant has
370
+ * read, then read broadcasts, then any other read message, oldest first. Unread mail, a question awaiting
371
+ * a live target and the question being answered are never evicted; undefined means only they remain.
372
+ */
373
+ private eviction(bytes: number, maxMessages: number, maxBytes: number, replyTarget?: number): Stored[] | undefined {
374
+ let count = this.messages.length + 1;
375
+ let size = this.size + bytes;
376
+ const fits = () => count <= maxMessages && size <= maxBytes;
377
+ const chosen: Stored[] = [];
378
+ if (fits()) return chosen;
379
+ const taken = new Set<number>();
380
+ const take = (items: readonly Stored[]) => {
381
+ for (const item of items) { taken.add(item.id); chosen.push(item); count--; size -= item.bytes; }
382
+ };
383
+ // A question and its answers form one group; answers whose question was evicted form their own.
384
+ const groups = new Map<number, Stored[]>();
385
+ for (const message of this.messages) {
386
+ const root = message.replyTo ?? message.id;
387
+ const group = groups.get(root);
388
+ if (group) group.push(message); else groups.set(root, [message]);
389
+ }
390
+ // Never evicted: unread mail, the question being answered, and an unanswered question still waiting on a live target.
391
+ const disposable = (message: Stored) => message.id !== replyTarget && this.acknowledged(message);
392
+ for (const [root, group] of groups) {
393
+ if (fits()) return chosen;
394
+ const question = this.byId.get(root);
395
+ if ((!question || question.answerers!.size > 0) && group.every(disposable)) take(group);
396
+ }
397
+ for (const [root, group] of groups) {
398
+ if (fits()) return chosen;
399
+ const question = this.byId.get(root);
400
+ if (question && !question.to && question.answerers!.size === 0 && group.every(disposable)) take(group);
401
+ }
402
+ for (const message of this.messages) {
403
+ if (fits()) return chosen;
404
+ const awaiting = message.kind === "question" && !!message.to && message.answerers!.size === 0 && this.agents.has(message.to);
405
+ if (!taken.has(message.id) && !awaiting && disposable(message)) take([message]);
406
+ }
407
+ return fits() ? chosen : undefined;
408
+ }
409
+
410
+ private remove(ids: ReadonlySet<number>): void {
411
+ this.messages = this.messages.filter((message) => !ids.has(message.id));
412
+ for (const id of ids) {
413
+ this.size -= this.byId.get(id)!.bytes;
414
+ this.byId.delete(id);
415
+ }
416
+ for (const agent of this.agents.values()) for (const id of ids) agent.seen.delete(id);
417
+ }
418
+ }