pi-roundtable 0.22.5 → 0.23.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
package/src/core/holds.ts CHANGED
@@ -44,6 +44,9 @@ export interface HoldContext {
44
44
  * signal that is already aborted when the hold answers refuses the call without a card, and
45
45
  * drops a confirmation of it, which is how a hold withdraws what a restart left held: the
46
46
  * signals do not survive a restart, but a repeated call is described again.
47
+ *
48
+ * `classes` are the hold classes of the call, for a rule that describes it in a turn (see
49
+ * `HoldRule.holdClasses`); a rule that leaves them out leaves its call unclassed.
47
50
  */
48
51
  export type HoldDescription =
49
52
  | string
@@ -52,6 +55,7 @@ export type HoldDescription =
52
55
  copy?: NonNullable<ApprovalDetails["copy"]>;
53
56
  supersede?: AbortSignal;
54
57
  approval?: "speaker";
58
+ classes?: readonly string[];
55
59
  };
56
60
 
57
61
  /**
@@ -60,11 +64,34 @@ export type HoldDescription =
60
64
  */
61
65
  export interface HoldRule {
62
66
  name: string;
67
+ /**
68
+ * Whether the rule stands for other rules' calls, as the precheck scripts' does, and may report
69
+ * their ids in `holdClasses`. Any other rule may report only ids that start with its own name
70
+ * and a dot. A relay's calls also carry `<name>.relay`, so `autonomy.yolo.card` naming the relay
71
+ * cards them.
72
+ */
73
+ relay?: true;
63
74
  describe(
64
75
  tool: string,
65
76
  input: Record<string, unknown>,
66
77
  context: HoldContext,
67
78
  ): string | undefined;
79
+ /**
80
+ * The classes of a call this rule holds, as `<rule>.<class>` ids such as `shell.rm`, for yolo
81
+ * mode to tell a call it may approve from one it never does (see `autonomy.yolo`). Report only
82
+ * classes that are safe to approve with no person looking: listing a class opts the call in, and
83
+ * yolo then approves it unless `autonomy.yolo.card` names the class or the rule. List every
84
+ * reason the call is held, not only the first: yolo approves only a call whose classes it all
85
+ * allows. Every id starts with the rule's name and a dot; a `relay` rule, which stands for other
86
+ * rules' calls, may list their ids instead. A rule that reports none,
87
+ * or an id that is not so named, leaves the call unclassed: yolo cards it unless
88
+ * `autonomy.yolo.allow` names the rule. Asked for a call `describe` held. It must change nothing.
89
+ */
90
+ holdClasses?(
91
+ tool: string,
92
+ input: Record<string, unknown>,
93
+ context: HoldContext,
94
+ ): readonly string[] | undefined;
68
95
  /**
69
96
  * Whether this rule may hold some call of `tool`, for a rule whose verdict depends on the input
70
97
  * (such as an action argument). Asked when the input is not known yet, as for a precheck
@@ -119,6 +146,12 @@ export interface HoldRule {
119
146
  mayName?(tool: string): boolean;
120
147
  }
121
148
 
149
+ /** The description of a held call with its hold classes. */
150
+ export interface ClassedHold {
151
+ description: HoldDescription;
152
+ classes: readonly string[];
153
+ }
154
+
122
155
  /** The description of a call that must be approved first, or undefined to let it run. */
123
156
  export type HoldCheck = ((
124
157
  tool: string,
@@ -127,6 +160,17 @@ export type HoldCheck = ((
127
160
  ) => string | undefined) & {
128
161
  /** The name of a rule that may hold some call of `tool` whatever its input; see `HoldRule.mayHold`. */
129
162
  mayHold?(tool: string): string | undefined;
163
+ /**
164
+ * The classes of the call, from every rule whose `describe` holds it; see
165
+ * `HoldRule.holdClasses`. A rule without valid classes of its own gives its bare name, which
166
+ * marks the call unclassed. Undefined when no rule holds the call. A check made without `holdChain` has none, and yolo
167
+ * mode then never approves its calls.
168
+ */
169
+ classesOf?(
170
+ tool: string,
171
+ input: Record<string, unknown>,
172
+ context: HoldContext,
173
+ ): readonly string[] | undefined;
130
174
  /**
131
175
  * The check as a turn runs it: each rule's `describeInTurn`, or its `describe` for a rule
132
176
  * without one, asked in order until one describes the call. It answers at once when every rule
@@ -138,6 +182,18 @@ export type HoldCheck = ((
138
182
  context: HoldContext,
139
183
  turn: ToolTurn,
140
184
  ): HoldDescription | undefined | Promise<HoldDescription | undefined>;
185
+ /**
186
+ * `inTurn`, with the classes of every rule that holds the call, as the confirmation gate records
187
+ * them with it; see `HoldRule.holdClasses`. The rules after the first that holds the call are
188
+ * asked too, and one whose lookup fails leaves its bare name. A description that names its own
189
+ * `classes` keeps them. A check without it has no classes for its calls.
190
+ */
191
+ inTurnWithClasses?(
192
+ tool: string,
193
+ input: Record<string, unknown>,
194
+ context: HoldContext,
195
+ turn: ToolTurn,
196
+ ): ClassedHold | undefined | Promise<ClassedHold | undefined>;
141
197
  /** The highest tier any rule requires to approve the call; see `HoldRule.approvalTier`. */
142
198
  approvalTier?(
143
199
  tool: string,
@@ -173,6 +229,10 @@ export function higherTier(
173
229
  export function holdChain(rules: readonly HoldRule[]): HoldCheck {
174
230
  const names = new Set<string>();
175
231
  for (const rule of rules) {
232
+ if (rule.name.includes("."))
233
+ throw new PluginError(
234
+ `hold rule ${rule.name}: the name has a dot, which separates a rule from its hold classes. Rename it.`,
235
+ );
176
236
  if (names.has(rule.name))
177
237
  throw new PluginError(
178
238
  `hold rule ${rule.name} is registered twice. Rename one of the two.`,
@@ -186,25 +246,90 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
186
246
  }
187
247
  return undefined;
188
248
  };
189
- check.inTurn = (tool, input, context, turn) => {
190
- const ask = (
191
- from: number,
192
- ): HoldDescription | undefined | Promise<HoldDescription | undefined> => {
193
- for (let at = from; at < rules.length; at++) {
194
- const rule = rules[at];
195
- if (!rule) continue;
196
- const description = rule.describeInTurn
249
+ check.classesOf = (tool, input, context) => {
250
+ const holding = rules.filter(
251
+ (r) => r.describe(tool, input, context) !== undefined,
252
+ );
253
+ return holding.length === 0
254
+ ? undefined
255
+ : unionOf(
256
+ holding.map((rule) => classesFor(rule, names, tool, input, context)),
257
+ );
258
+ };
259
+ /**
260
+ * Asks the rules from `from` in order, each holding rule joining `found`. Stops at the first
261
+ * holding rule unless `all`; then a later rule whose lookup fails holds the call undescribed.
262
+ */
263
+ const ask = (
264
+ tool: string,
265
+ input: Record<string, unknown>,
266
+ context: HoldContext,
267
+ turn: ToolTurn,
268
+ from: number,
269
+ all: boolean,
270
+ found: readonly Holding[] = [],
271
+ ): readonly Holding[] | Promise<readonly Holding[]> => {
272
+ let holding = found;
273
+ for (let at = from; at < rules.length; at++) {
274
+ const rule = rules[at];
275
+ if (!rule) continue;
276
+ let description: ReturnType<NonNullable<HoldRule["describeInTurn"]>>;
277
+ try {
278
+ description = rule.describeInTurn
197
279
  ? rule.describeInTurn(tool, input, context, turn)
198
280
  : rule.describe(tool, input, context);
199
- if (isPromise(description))
200
- return description.then((found) =>
201
- found !== undefined ? found : ask(at + 1),
202
- );
203
- if (description !== undefined) return description;
281
+ } catch (error) {
282
+ if (holding.length === 0) throw error;
283
+ holding = [...holding, { description: undefined, rule }];
284
+ continue;
285
+ }
286
+ if (isPromise(description)) {
287
+ const before = holding;
288
+ return description.then(
289
+ (answer) => {
290
+ const next =
291
+ answer === undefined
292
+ ? before
293
+ : [...before, { description: answer, rule }];
294
+ return all || next.length === 0
295
+ ? ask(tool, input, context, turn, at + 1, all, next)
296
+ : next;
297
+ },
298
+ (error) => {
299
+ if (before.length === 0) throw error;
300
+ return ask(tool, input, context, turn, at + 1, all, [
301
+ ...before,
302
+ { description: undefined, rule },
303
+ ]);
304
+ },
305
+ );
204
306
  }
205
- return undefined;
307
+ if (description !== undefined) {
308
+ holding = [...holding, { description, rule }];
309
+ if (!all) return holding;
310
+ }
311
+ }
312
+ return holding;
313
+ };
314
+ check.inTurn = (tool, input, context, turn) => {
315
+ const found = ask(tool, input, context, turn, 0, false);
316
+ return isPromise(found)
317
+ ? found.then((holding) => holding[0]?.description)
318
+ : found[0]?.description;
319
+ };
320
+ check.inTurnWithClasses = (tool, input, context, turn) => {
321
+ const classed = (holding: readonly Holding[]) => {
322
+ const first = holding.find((held) => held.description !== undefined);
323
+ if (first?.description === undefined) return undefined;
324
+ return {
325
+ description: first.description,
326
+ classes: unionOf(
327
+ holding.map((held) => classesIn(held, names, tool, input, context)),
328
+ ),
329
+ };
206
330
  };
207
- return ask(0);
331
+ const found = ask(tool, input, context, turn, 0, true);
332
+ return isPromise(found) ? found.then(classed) : classed(found);
208
333
  };
209
334
  check.mayHold = (tool) => rules.find((rule) => rule.mayHold?.(tool))?.name;
210
335
  check.approvalTier = (tool, input, context) =>
@@ -232,6 +357,87 @@ export function holdChain(rules: readonly HoldRule[]): HoldCheck {
232
357
  return check;
233
358
  }
234
359
 
360
+ /** The classes of a call a rule holds: its own, or the rule's bare name, which leaves the call unclassed. */
361
+ function classesFor(
362
+ rule: HoldRule,
363
+ names: ReadonlySet<string>,
364
+ tool: string,
365
+ input: Record<string, unknown>,
366
+ context: HoldContext,
367
+ ): readonly string[] {
368
+ let own: unknown;
369
+ try {
370
+ own = rule.holdClasses?.(tool, input, context);
371
+ } catch {
372
+ // A rule whose classes fail leaves its call unclassed.
373
+ own = undefined;
374
+ }
375
+ return validClasses(own, rule, names);
376
+ }
377
+
378
+ /**
379
+ * The ids a rule reports once each, when it reports some and every one starts with the name of a
380
+ * rule of the chain and a dot or is that name; otherwise the rule's bare name, which yolo mode
381
+ * treats as unclassed and cards.
382
+ */
383
+ function validClasses(
384
+ reported: unknown,
385
+ rule: HoldRule,
386
+ names: ReadonlySet<string>,
387
+ ): readonly string[] {
388
+ if (!Array.isArray(reported) || reported.length === 0) return [rule.name];
389
+ const ids = reported as unknown[];
390
+ if (!ids.every((id): id is string => isClassOf(id, rule, names)))
391
+ return [rule.name];
392
+ return [
393
+ ...new Set(rule.relay ? [...ids, `${rule.name}.relay`] : ids),
394
+ ] as string[];
395
+ }
396
+
397
+ /** A rule's description, if it gave one, and the rule; a rule whose lookup failed has none. */
398
+ interface Holding {
399
+ description: HoldDescription | undefined;
400
+ rule: HoldRule;
401
+ }
402
+
403
+ /** The classes of a held call: those its description names, else its rule's. */
404
+ function classesIn(
405
+ { description, rule }: Holding,
406
+ names: ReadonlySet<string>,
407
+ tool: string,
408
+ input: Record<string, unknown>,
409
+ context: HoldContext,
410
+ ): readonly string[] {
411
+ // A rule whose lookup failed could not say what the call is.
412
+ if (description === undefined) return [rule.name];
413
+ if (typeof description === "object" && description !== null) {
414
+ const named = description.classes;
415
+ if (Array.isArray(named) && named.length > 0)
416
+ return validClasses(named, rule, names);
417
+ }
418
+ return classesFor(rule, names, tool, input, context);
419
+ }
420
+
421
+ /**
422
+ * Whether a rule may report the id: it is the rule's own name or one of its classes, or the rule
423
+ * is a relay and the id is a rule of the chain or one of that rule's classes.
424
+ */
425
+ function isClassOf(
426
+ id: unknown,
427
+ rule: HoldRule,
428
+ names: ReadonlySet<string>,
429
+ ): id is string {
430
+ if (typeof id !== "string") return false;
431
+ const dot = id.indexOf(".");
432
+ const owner = dot === -1 ? id : id.slice(0, dot);
433
+ return rule.relay ? names.has(owner) : owner === rule.name;
434
+ }
435
+
436
+ /** The ids of the lists once each, in order. */
437
+ function unionOf(lists: readonly (readonly string[])[]): readonly string[] {
438
+ return [...new Set(lists.flat())];
439
+ }
440
+
235
441
  /** The principals every list names, in the first list's order; undefined when there are no lists. */
236
442
  function commonApprovers(
237
443
  lists: readonly (readonly string[] | undefined)[],
@@ -8,7 +8,7 @@ import type { SurfacePort } from "./contract/surface.ts";
8
8
  import { NotLinkedError } from "./errors.ts";
9
9
  import { EventBus } from "./events.ts";
10
10
  import type { RoundtableOptions } from "./host.ts";
11
- import { HttpListeners } from "./http/listeners.ts";
11
+ import { HttpListeners, listenerAddress } from "./http/listeners.ts";
12
12
  import { setLocale } from "./i18n/index.ts";
13
13
  import { contactsOf } from "./identity/identity-view.ts";
14
14
  import type {
@@ -178,6 +178,7 @@ export class Linkage {
178
178
  return this.registry.dashboard;
179
179
  },
180
180
  apiKey: async (provider) => this.#options.apiKey?.(provider),
181
+ listener: (id) => listenerAddress(listeners, id),
181
182
  },
182
183
  this.tiers,
183
184
  services,
@@ -20,6 +20,14 @@ export interface HttpRoute {
20
20
  path: { exact: string } | { prefix: string };
21
21
  /** Omitted: every method, for a handler that answers the rest itself. */
22
22
  methods?: readonly string[];
23
+ /**
24
+ * The most bytes of request body this route takes, such as a plugin's upload limit plus its
25
+ * multipart overhead. The listener serves the largest value among its routes (a route that
26
+ * says nothing counts as `DEFAULT_MAX_REQUEST_BODY_BYTES`) unless the host set the listener's
27
+ * own `maxRequestBodyBytes`, which decides; a request over it is answered 413 before the
28
+ * handler runs. A positive integer.
29
+ */
30
+ maxRequestBodyBytes?: number;
23
31
  handle(request: Request): Response | Promise<Response>;
24
32
  /**
25
33
  * Takes the route's WebSocket upgrade requests (GET with `Upgrade: websocket`); every other
@@ -37,8 +45,44 @@ export type ListenerAddress =
37
45
  }
38
46
  | { port: number; hostname?: string };
39
47
 
40
- /** An address the host serves HTTP on, with the id routes name it by. */
41
- export type ListenerConfig = { id: string } & ListenerAddress;
48
+ /**
49
+ * An address the host serves HTTP on, with the id routes name it by. `maxRequestBodyBytes` is the
50
+ * most bytes of request body the listener takes (a positive integer); without it the listener
51
+ * takes what its routes ask for, see `HttpRoute.maxRequestBodyBytes`.
52
+ */
53
+ export type ListenerConfig = {
54
+ id: string;
55
+ maxRequestBodyBytes?: number;
56
+ } & ListenerAddress;
57
+
58
+ /** Bun's own default request body limit, 128 MiB, which a listener sets explicitly. */
59
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 128 * 1024 * 1024;
60
+
61
+ /** The request body limit a listener is opened with: its own option, else its routes' largest ask, else the default. */
62
+ export function requestBodyLimit(
63
+ listener: ListenerConfig,
64
+ routes: readonly HttpRoute[],
65
+ ): number {
66
+ if (listener.maxRequestBodyBytes !== undefined)
67
+ return listener.maxRequestBodyBytes;
68
+ const asked = routes.map(
69
+ (route) => route.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES,
70
+ );
71
+ return asked.length === 0
72
+ ? DEFAULT_MAX_REQUEST_BODY_BYTES
73
+ : Math.max(...asked);
74
+ }
75
+
76
+ /** The address of the listener `id`, without the id, as a copy a plugin cannot use to change the host's. */
77
+ export function listenerAddress(
78
+ listeners: readonly ListenerConfig[],
79
+ id: string,
80
+ ): Readonly<ListenerAddress> | undefined {
81
+ const found = listeners.find((listener) => listener.id === id);
82
+ if (!found) return undefined;
83
+ const { id: _id, maxRequestBodyBytes: _limit, ...address } = found;
84
+ return Object.freeze(address);
85
+ }
42
86
 
43
87
  const pathOf = (route: HttpRoute) =>
44
88
  "exact" in route.path ? route.path.exact : route.path.prefix;
@@ -88,6 +132,14 @@ function validateLimits(name: string, websocket: WebSocketRoute): void {
88
132
  );
89
133
  }
90
134
 
135
+ /** Refuses a request body limit that is not a positive integer, naming what set it. */
136
+ function validateBodyLimit(owner: string, value: number | undefined): void {
137
+ if (value !== undefined && !(Number.isSafeInteger(value) && value > 0))
138
+ throw new PluginError(
139
+ `${owner} sets maxRequestBodyBytes to ${value}, which is not a positive integer`,
140
+ );
141
+ }
142
+
91
143
  /** Refuses routes on unknown listeners, reused names, and any request two routes could both take. */
92
144
  function validate(
93
145
  listeners: readonly ListenerConfig[],
@@ -96,7 +148,10 @@ function validate(
96
148
  const ids = new Set(listeners.map((l) => l.id));
97
149
  if (ids.size !== listeners.length)
98
150
  throw new PluginError("a listener is configured twice");
151
+ for (const listener of listeners)
152
+ validateBodyLimit(`listener ${listener.id}`, listener.maxRequestBodyBytes);
99
153
  routes.forEach((route, index) => {
154
+ validateBodyLimit(`route ${route.name}`, route.maxRequestBodyBytes);
100
155
  if (route.websocket && route.methods && !route.methods.includes("GET"))
101
156
  throw new PluginError(
102
157
  `route ${route.name} takes WebSockets, so its methods must include GET`,
@@ -198,11 +253,13 @@ function serveTcp(
198
253
  hostname: string | undefined,
199
254
  fetch: Fetch,
200
255
  websocket: WebSocketHandler<Connection> | undefined,
256
+ maxRequestBodySize: number,
201
257
  ): Server<Connection> {
202
258
  const options = {
203
259
  port,
204
260
  ...(hostname ? { hostname } : {}),
205
261
  idleTimeout: 0,
262
+ maxRequestBodySize,
206
263
  fetch,
207
264
  error: serverError,
208
265
  ...(websocket ? { websocket } : {}),
@@ -256,18 +313,20 @@ export class HttpListeners {
256
313
  sockets: this.#sockets,
257
314
  });
258
315
  const websocket = this.#websocketHandler(listener.id, routes);
316
+ const maxRequestBodySize = requestBodyLimit(listener, routes);
259
317
  if ("socketPath" in listener) {
260
318
  this.#servers.push(
261
319
  websocket
262
320
  ? serveUnix(listener.socketPath, handle, {
263
321
  error: serverError,
322
+ maxRequestBodySize,
264
323
  websocket,
265
324
  })
266
325
  : serveUnix(
267
326
  listener.socketPath,
268
327
  (request) =>
269
328
  routeRequest(routes, request, listener.id, this.#logger),
270
- { error: serverError },
329
+ { error: serverError, maxRequestBodySize },
271
330
  ),
272
331
  );
273
332
  chmodSync(listener.socketPath, listener.mode ?? 0o660);
@@ -277,6 +336,7 @@ export class HttpListeners {
277
336
  listener.hostname,
278
337
  handle,
279
338
  websocket,
339
+ maxRequestBodySize,
280
340
  );
281
341
  this.#servers.push(server);
282
342
  if (server.port !== undefined)
@@ -20,17 +20,11 @@ export function agentPanelEn(ctx: CatalogContext) {
20
20
  skills: string;
21
21
  }) =>
22
22
  `### ${v.displayName}\n-# agent name \`${v.name}\`, cannot be changed\n**Model** · \`${v.model}\` · thinking \`${v.thinking}\`${v.follows}\n${v.skills}\n-# To add or remove a skill, ask any agent to use agent_skills.`,
23
- agentAvatarPromptText: (prompt: string) => `**Avatar prompt**\n${prompt}`,
24
- agentNoImageProvider:
25
- "**Avatar** · No image provider is configured, so this avatar is generated from the agent's display name and cannot be redrawn or edited. A plugin that fills the `images` slot adds drawing.",
26
23
  agentPromptInline: (prompt: string) =>
27
24
  `**Prompt**\n\`\`\`\n${prompt}\n\`\`\``,
28
25
  agentPromptFile: (length: number) =>
29
26
  `**Prompt** · ${length} characters, the full text is in the file below.`,
30
27
  agentEditPromptLabel: "Edit prompt",
31
- agentRedrawLabel: "Redraw avatar",
32
- agentNewAvatarPromptLabel: "New avatar prompt",
33
- agentEditAvatarLabel: "Edit avatar",
34
28
  agentModelLabel: "Model",
35
29
  agentDefaultNote:
36
30
  "A changed prompt takes effect from this agent's next turn; the conversation history is kept.",
@@ -41,12 +35,15 @@ export function agentPanelEn(ctx: CatalogContext) {
41
35
  agentEditModalTitle: (displayName: string) => `Edit ${displayName}`,
42
36
  agentDisplayNameLabel: "Display name",
43
37
  agentPromptLabel: "Prompt",
44
- agentAvatarPromptLabel: "Avatar prompt (state the background colour)",
45
- agentEditAvatarField: "How to change it (for example: add a hat)",
38
+ agentLook: (label: string, color: string) =>
39
+ `**Look** · ${label}, ${color}`,
40
+ agentClassPlaceholder: "Class",
41
+ agentColorPlaceholder: "Color",
42
+ agentLookChanged: "The new picture shows from this agent's next message.",
43
+ agentLookFailed: (message: string) =>
44
+ `The look was not changed: ${message}`,
45
+ agentPanelOutdated: `This panel is out of date; open it again with \`/${ctx.root} profile\`.`,
46
46
  agentSaved: "Saved; it takes effect from the next turn.",
47
- agentAvatarRedrawn: "The new avatar shows from this agent's next message.",
48
- agentAvatarFailed: (message: string) =>
49
- `The avatar was not drawn: ${message}`,
50
47
  };
51
48
  }
52
49
 
@@ -70,17 +67,11 @@ export function agentPanelZhTW(
70
67
  skills: string;
71
68
  }) =>
72
69
  `### ${v.displayName}\n-# agent 名稱 \`${v.name}\`,不能更改\n**模型** \`${v.model}\` thinking \`${v.thinking}\`${v.follows}\n${v.skills}\n-# 要增減 skill,請任一個 agent 用 agent_skills。`,
73
- agentAvatarPromptText: (prompt: string) => `**頭像提示詞**\n${prompt}`,
74
- agentNoImageProvider:
75
- "**頭像** 尚未設定圖像提供者,所以這個頭像由 agent 的顯示名自動產生,不能重畫或修改。要能畫頭像,需要一個填入 `images` 槽位的外掛。",
76
70
  agentPromptInline: (prompt: string) =>
77
71
  `**提示詞**\n\`\`\`\n${prompt}\n\`\`\``,
78
72
  agentPromptFile: (length: number) =>
79
73
  `**提示詞** ${length} 字,完整內容在下面的檔案。`,
80
74
  agentEditPromptLabel: "編輯提示詞",
81
- agentRedrawLabel: "重畫頭像",
82
- agentNewAvatarPromptLabel: "新頭像提示詞",
83
- agentEditAvatarLabel: "修改頭像",
84
75
  agentModelLabel: "模型",
85
76
  agentDefaultNote:
86
77
  "提示詞改完,從這個 agent 的下一回合開始生效;對話紀錄保留。",
@@ -91,10 +82,12 @@ export function agentPanelZhTW(
91
82
  agentEditModalTitle: (displayName: string) => `編輯 ${displayName}`,
92
83
  agentDisplayNameLabel: "顯示名",
93
84
  agentPromptLabel: "提示詞",
94
- agentAvatarPromptLabel: "頭像提示詞(寫明背景色)",
95
- agentEditAvatarField: "要怎麼改(例如:加一頂帽子)",
85
+ agentLook: (label: string, color: string) => `**外觀** ${label},${color}`,
86
+ agentClassPlaceholder: "職業",
87
+ agentColorPlaceholder: "顏色",
88
+ agentLookChanged: "新的頭像從這個 agent 的下一則訊息開始顯示。",
89
+ agentLookFailed: (message: string) => `外觀沒有改成:${message}`,
90
+ agentPanelOutdated: `這個面板已經過期,請重新用 \`/${ctx.root} profile\` 開啟。`,
96
91
  agentSaved: "已儲存,從下一回合開始生效。",
97
- agentAvatarRedrawn: "新頭像從這個 agent 的下一則訊息開始顯示。",
98
- agentAvatarFailed: (message: string) => `頭像沒有畫成:${message}`,
99
92
  };
100
93
  }
@@ -78,6 +78,12 @@ export interface ApprovedBy {
78
78
  * approval without it, from anyone else, is not accepted.
79
79
  */
80
80
  owner?: true;
81
+ /**
82
+ * Set only when the server approved the call itself and no person answered: `"yolo"` is the
83
+ * conversation's yolo mode. A surface that reports an answer from a person never sets it;
84
+ * `ToolTurn.approvedBy` shows it to the tool.
85
+ */
86
+ auto?: "yolo";
81
87
  }
82
88
 
83
89
  /**
@@ -133,6 +139,12 @@ export interface ApprovalOptions {
133
139
  * such as a turn's timeout card; a surface that cannot show them keeps its own.
134
140
  */
135
141
  wording?: ApprovalWording;
142
+ /**
143
+ * What the card asks: `"action"` (the default, also when absent) whether to run a held call,
144
+ * `"turn-timeout"` whether to let a turn that reached its time limit go on. A surface may show
145
+ * the two differently or notify on the second; Discord ignores it.
146
+ */
147
+ purpose?: "action" | "turn-timeout";
136
148
  }
137
149
 
138
150
  /** A card's words in place of an approval's; see `ApprovalOptions.wording`. */
@@ -0,0 +1,23 @@
1
+ /** How far the host lets a conversation run without a person: the limits behind its modes. */
2
+ export interface Autonomy {
3
+ yolo: {
4
+ /**
5
+ * Hold classes yolo may approve although it cards them by default, such as `shell.rm`, and the
6
+ * names of rules that report no classes, such as `tool:repo_push`, whose calls it then approves.
7
+ */
8
+ allow: readonly string[];
9
+ /** Hold classes or rule names yolo never approves, such as `shell.push` or `tool:repo_push`. */
10
+ card: readonly string[];
11
+ };
12
+ /** The most one long-running turn works in all before it asks or stops, in ms. */
13
+ longRunningMaxMs: number;
14
+ }
15
+
16
+ /** The longest a long-running turn works without a person: four hours. */
17
+ export const DEFAULT_LONG_RUNNING_MS = 4 * 60 * 60_000;
18
+
19
+ /** Yolo approves nothing beyond its built-in classes, and a long-running turn stops at four hours. */
20
+ export const DEFAULT_AUTONOMY: Autonomy = Object.freeze({
21
+ yolo: Object.freeze({ allow: [], card: [] }),
22
+ longRunningMaxMs: DEFAULT_LONG_RUNNING_MS,
23
+ });