pi-roundtable 0.27.1 → 0.28.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 (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/README.zh-TW.md +2 -0
  4. package/docs/plugins.md +29 -13
  5. package/package.json +1 -1
  6. package/src/characters/compose.ts +11 -0
  7. package/src/characters/parts-face.ts +23 -2
  8. package/src/core/agents/agent-messages.ts +32 -85
  9. package/src/core/agents/agent-ports.ts +3 -0
  10. package/src/core/agents/agent-prompt.ts +2 -16
  11. package/src/core/agents/agent-tools.ts +1 -1
  12. package/src/core/agents/helper-create.ts +3 -24
  13. package/src/core/agents/helper-modes.ts +64 -0
  14. package/src/core/agents/team-turn-types.ts +7 -13
  15. package/src/core/agents/team-turns.ts +7 -2
  16. package/src/core/builtin/agent-server.ts +3 -4
  17. package/src/core/builtin/discord.ts +35 -8
  18. package/src/core/config/adapters.ts +2 -1
  19. package/src/core/config/limits.ts +35 -2
  20. package/src/core/discord/agent-discord.ts +12 -1
  21. package/src/core/discord/card-copies.ts +7 -2
  22. package/src/core/discord/discord-surface.ts +13 -30
  23. package/src/core/discord/intercom-cards.ts +121 -0
  24. package/src/core/discord/owner-card-asks.ts +9 -0
  25. package/src/core/discord/owner-card-types.ts +2 -0
  26. package/src/core/discord/owner-cards.ts +2 -1
  27. package/src/core/discord/stop-button.ts +35 -0
  28. package/src/core/domain/conversation.ts +18 -0
  29. package/src/core/domain/interim.ts +7 -0
  30. package/src/core/domain/turn-timeout.ts +5 -0
  31. package/src/core/i18n/conversation.ts +16 -4
  32. package/src/core/i18n/turn-timeout.ts +10 -2
  33. package/src/core/interactions/prompts.ts +5 -0
  34. package/src/core/modes/autonomy.ts +22 -1
  35. package/src/core/modes/conversation-modes.ts +135 -23
  36. package/src/core/modes/mode-inheritance.ts +78 -0
  37. package/src/core/modes/modes-plugin.ts +1 -1
  38. package/src/core/modules/skills/skill-contract.ts +2 -0
  39. package/src/core/presentation/intercom.ts +44 -0
  40. package/src/core/runtime/ask-questions.ts +21 -4
  41. package/src/core/runtime/extensions/ask-user-text.ts +36 -3
  42. package/src/core/runtime/extensions/ask-user.ts +21 -1
  43. package/src/core/runtime/interim-text.ts +7 -4
  44. package/src/core/runtime/pi-agent-runtime.ts +2 -2
  45. package/src/core/runtime/prompt-slot.ts +11 -1
  46. package/src/core/runtime/session-factory.ts +11 -6
  47. package/src/core/runtime/turn-budget.ts +34 -3
  48. package/src/core/runtime/turn-limits.ts +13 -3
  49. package/src/index.ts +1 -0
@@ -0,0 +1,121 @@
1
+ import {
2
+ ActionRowBuilder,
3
+ AttachmentBuilder,
4
+ ButtonBuilder,
5
+ ButtonStyle,
6
+ type Interaction,
7
+ MessageFlags,
8
+ } from "discord.js";
9
+ import type { IntercomCard } from "../domain/conversation.ts";
10
+ import { messages } from "../i18n/index.ts";
11
+ import {
12
+ intercomFolds,
13
+ intercomHeading,
14
+ intercomSummary,
15
+ } from "../presentation/intercom.ts";
16
+ import { splitReply } from "../presentation/reply-splitter.ts";
17
+ import type { InteractionModule } from "./interaction-module.ts";
18
+
19
+ /** The custom id of a card's open button starts with this; the text's id follows. */
20
+ export const INTERCOM_BUTTON_PREFIX = "roundtable:intercom:";
21
+ /** The most messages an opened card's text takes; a longer text comes as one file. */
22
+ const MAX_PARTS = 5;
23
+
24
+ /** The whole texts of the cards, by the id their open button carries; `CardCopies` keeps them. */
25
+ export interface IntercomTexts {
26
+ remember(id: string, text: string): void;
27
+ recall(id: string): string | undefined;
28
+ }
29
+
30
+ /**
31
+ * A card's message: one line of small text, its heading and summary, and an open button when the
32
+ * summary leaves part of the text out and the text was kept under `id`.
33
+ */
34
+ export function intercomMessage(card: IntercomCard, id: string | undefined) {
35
+ const line = messages().intercomLine(
36
+ intercomHeading(card),
37
+ intercomSummary(card.text),
38
+ );
39
+ return {
40
+ content: `-# ${line}`,
41
+ allowedMentions: { parse: [] as never[] },
42
+ ...(id === undefined
43
+ ? {}
44
+ : {
45
+ components: [
46
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
47
+ new ButtonBuilder()
48
+ .setCustomId(`${INTERCOM_BUTTON_PREFIX}${id}`)
49
+ .setLabel(messages().intercomExpand)
50
+ .setStyle(ButtonStyle.Secondary),
51
+ ),
52
+ ],
53
+ withComponents: true,
54
+ }),
55
+ };
56
+ }
57
+
58
+ /** A card's message, its whole text kept in `texts` for the open button when the summary leaves part out. */
59
+ export function intercomPost(card: IntercomCard, texts: IntercomTexts) {
60
+ if (!intercomFolds(card)) return intercomMessage(card, undefined);
61
+ const id = crypto.randomUUID();
62
+ texts.remember(id, card.text);
63
+ return intercomMessage(card, id);
64
+ }
65
+
66
+ /**
67
+ * What a press of the open button answers, seen only by whoever pressed: the whole text in
68
+ * messages within Discord's limit, or one file when it would take more than `MAX_PARTS`.
69
+ */
70
+ export function intercomReplies(text: string) {
71
+ const flags = MessageFlags.Ephemeral as const;
72
+ const allowedMentions = { parse: [] as never[] };
73
+ const parts = splitReply(text);
74
+ if (parts.length <= MAX_PARTS)
75
+ return parts.map((content) => ({ content, flags, allowedMentions }));
76
+ return [
77
+ {
78
+ content: messages().cardCopyFile,
79
+ files: [
80
+ new AttachmentBuilder(Buffer.from(text, "utf8"), {
81
+ name: "message.md",
82
+ }),
83
+ ],
84
+ flags,
85
+ allowedMentions,
86
+ },
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Answers a press of a card's open button with the card's whole text, to whoever pressed; a text
92
+ * no longer kept is said to be gone.
93
+ */
94
+ export function intercomButtonModule(
95
+ texts: Pick<IntercomTexts, "recall">,
96
+ ): InteractionModule {
97
+ return {
98
+ commands: () => [],
99
+ handle: async (interaction: Interaction) => {
100
+ if (
101
+ !interaction.isButton() ||
102
+ !interaction.customId.startsWith(INTERCOM_BUTTON_PREFIX)
103
+ )
104
+ return false;
105
+ const text = texts.recall(
106
+ interaction.customId.slice(INTERCOM_BUTTON_PREFIX.length),
107
+ );
108
+ if (!text) {
109
+ await interaction.reply({
110
+ content: messages().intercomGone,
111
+ flags: MessageFlags.Ephemeral,
112
+ });
113
+ return true;
114
+ }
115
+ const [first, ...rest] = intercomReplies(text);
116
+ await interaction.reply(first ?? { content: text });
117
+ for (const part of rest) await interaction.followUp(part);
118
+ return true;
119
+ },
120
+ };
121
+ }
@@ -45,6 +45,9 @@ export function askPrompts(args: {
45
45
  ? {
46
46
  wait: {
47
47
  pending: "pending",
48
+ ...(wait.graceMs === undefined
49
+ ? {}
50
+ : { graceMs: wait.graceMs }),
48
51
  late: (value, by) =>
49
52
  wait.late({
50
53
  kind: "question",
@@ -80,6 +83,9 @@ export function askPrompts(args: {
80
83
  ? {
81
84
  wait: {
82
85
  pending: "pending",
86
+ ...(wait.graceMs === undefined
87
+ ? {}
88
+ : { graceMs: wait.graceMs }),
83
89
  late: (value, by) =>
84
90
  wait.late({
85
91
  kind: "questionnaire",
@@ -123,6 +129,9 @@ export function askPrompts(args: {
123
129
  ? {
124
130
  wait: {
125
131
  pending: "pending",
132
+ ...(wait.graceMs === undefined
133
+ ? {}
134
+ : { graceMs: wait.graceMs }),
126
135
  late: (value, by) =>
127
136
  wait.late({
128
137
  kind: "questionnaire",
@@ -120,6 +120,8 @@ export interface CardSpec<T> {
120
120
  by: Answerer,
121
121
  prompt: string,
122
122
  ): ReturnType<PromptWait["late"]>;
123
+ /** The turn's own grace period, in ms, in place of the cards' `graceMs`; see `PromptWait.graceMs`. */
124
+ graceMs?: number;
123
125
  };
124
126
  /** Hears who approved the card, before it resolves approved in its turn. */
125
127
  approved?(by: Answerer, prompt: string): void;
@@ -196,10 +196,11 @@ export class OwnerCards implements InteractionModule {
196
196
  cancelled: T,
197
197
  card: CardSpec<T>,
198
198
  ): Promise<T> {
199
- const { logger, graceMs = CARD_GRACE_MS, resume } = this.#options;
199
+ const { logger, resume } = this.#options;
200
200
  if (signal?.aborted || card.supersede?.aborted)
201
201
  return Promise.resolve(cancelled);
202
202
  const wait = resume ? card.wait : undefined;
203
+ const graceMs = wait?.graceMs ?? this.#options.graceMs ?? CARD_GRACE_MS;
203
204
  const id = randomUUID();
204
205
  if (card.copy !== undefined) this.#copies.remember(id, card.copy);
205
206
  const sections = [
@@ -3,7 +3,9 @@ import {
3
3
  ButtonBuilder,
4
4
  ButtonStyle,
5
5
  type Interaction,
6
+ type Message,
6
7
  MessageFlags,
8
+ type SendableChannels,
7
9
  } from "discord.js";
8
10
  import type { ConversationPort } from "../contract/channels.ts";
9
11
  import { channelKey } from "../contract/surface.ts";
@@ -11,6 +13,8 @@ import { messages } from "../i18n/index.ts";
11
13
  import type { CommandGuard, InteractionModule } from "./interaction-module.ts";
12
14
 
13
15
  export const STOP_BUTTON_ID = "roundtable:stop";
16
+ /** A turn shorter than this shows no stop button, so quick answers do not flicker one. */
17
+ const STOP_PANEL_DELAY_MS = 5_000;
14
18
 
15
19
  /** The message a long turn shows: a stop button only an owner may press. */
16
20
  export function stopPanel() {
@@ -28,6 +32,37 @@ export function stopPanel() {
28
32
  };
29
33
  }
30
34
 
35
+ /**
36
+ * Shows the stop panel in the channel `sendable` finds once the turn has run for
37
+ * `STOP_PANEL_DELAY_MS`; the returned function hides it, or keeps it from showing. A failure is
38
+ * handed to `warn` with what failed, never thrown.
39
+ */
40
+ export function showStopPanel(
41
+ sendable: () => Promise<SendableChannels>,
42
+ warn: (error: unknown, what: string) => void,
43
+ ): () => void {
44
+ let hidden = false;
45
+ let panel: Message | undefined;
46
+ const remove = (message: Message) =>
47
+ message
48
+ .delete()
49
+ .catch((error: unknown) => warn(error, "stop panel not removed"));
50
+ const timer = setTimeout(() => {
51
+ sendable()
52
+ .then((target): Promise<Message> => target.send(stopPanel()))
53
+ .then((message) => {
54
+ if (hidden) void remove(message);
55
+ else panel = message;
56
+ })
57
+ .catch((error: unknown) => warn(error, "stop panel not shown"));
58
+ }, STOP_PANEL_DELAY_MS);
59
+ return () => {
60
+ hidden = true;
61
+ clearTimeout(timer);
62
+ if (panel) void remove(panel);
63
+ };
64
+ }
65
+
31
66
  export interface StopButtonOptions {
32
67
  guard: Pick<CommandGuard, "allows">;
33
68
  /** Stops the running turn of the channel the button was pressed in, through its claim. */
@@ -72,6 +72,19 @@ export interface PostAuthor {
72
72
  agent?: string;
73
73
  }
74
74
 
75
+ /**
76
+ * A message between two agents of the team, which a surface that shows cards draws as a small card
77
+ * that opens to the whole text.
78
+ */
79
+ export interface IntercomCard {
80
+ /** `sent`: a request the author sent; `answered`: the answer that came back; `failed`: none came. */
81
+ kind: "sent" | "answered" | "failed";
82
+ /** The agent the request went to, which also gives the answer: its stable name and the name it shows. */
83
+ peer: { name: string; displayName: string };
84
+ /** The whole text: the request, the answer, or why no answer came. */
85
+ text: string;
86
+ }
87
+
75
88
  export interface OutboundReply {
76
89
  /** The assistant's thinking for this turn, posted as a quiet line before the card. */
77
90
  thinking?: string;
@@ -88,4 +101,9 @@ export interface OutboundReply {
88
101
  author?: PostAuthor;
89
102
  /** Set only on the reply for a turn that ended in an error. A surface may show it; Discord ignores it. */
90
103
  failed?: true;
104
+ /**
105
+ * Set on a message between agents: a surface that shows cards draws it in place of `chunks`,
106
+ * which carry the same as a short line and the text for a surface that shows none.
107
+ */
108
+ intercom?: IntercomCard;
91
109
  }
@@ -10,6 +10,13 @@ export interface InterimMessage {
10
10
  */
11
11
  export interface InterimPosts {
12
12
  post(text: string): Promise<InterimMessage>;
13
+ /**
14
+ * Whether the turn's progress message names the tools it called, on a line of its own; true
15
+ * unless false. A surface that shows the tools elsewhere, such as a progress pane, sets false:
16
+ * the progress message then carries only the turn's narration, and a run that wrote none posts
17
+ * nothing.
18
+ */
19
+ tools?: boolean;
13
20
  }
14
21
 
15
22
  /** Whether a turn posts its intermediate text as it goes; "on" by default. */
@@ -23,6 +23,11 @@ export interface TurnTimeout {
23
23
  tools: [string, number][];
24
24
  /** How long the timeout card waits for an answer. */
25
25
  graceMs: number;
26
+ /**
27
+ * The card goes on by itself when no one answers it in `graceMs`, as Continue would: the
28
+ * conversation is in yolo mode and the turn is short of the long-running cap.
29
+ */
30
+ autoContinue?: true;
26
31
  end: TurnTimeoutEnd;
27
32
  /** No timeout card could be shown, as in a schedule's turn, so nobody was asked whether to go on. */
28
33
  unasked?: true;
@@ -24,10 +24,17 @@ export function conversationEn(ctx: CatalogContext) {
24
24
  delegationTask: (task: string) => `📋 Task:\n${task}`,
25
25
  delegationDone: (report: string) => `✅ Done\n\n${report}`,
26
26
  delegationFailed: (error: string) => `❌ Failed: ${error}`,
27
- agentNoReply: (message: string) => `❌ No reply: ${message}`,
28
27
  messageDelivered: (displayName: string, text: string) =>
29
28
  `📨 To **${displayName}**:\n${text}`,
30
- answerReturned: (text: string) => `↩️ Reply:\n${text}`,
29
+ intercomSent: (displayName: string) => `→ To **${displayName}**`,
30
+ intercomAnswered: (displayName: string) => `← From **${displayName}**`,
31
+ intercomFailed: (displayName: string) =>
32
+ `⚠️ No answer from **${displayName}**`,
33
+ intercomLine: (heading: string, summary: string) =>
34
+ summary ? `${heading}: ${summary}` : heading,
35
+ intercomExpand: "Show all",
36
+ intercomGone:
37
+ "The full text of this message is no longer kept; it is still in the agents' conversations.",
31
38
  thinkingAuto: "auto",
32
39
  confirmTitle: (asker: string) => `${asker} wants to run this action`,
33
40
  holdGeneric: (tool: string) => `run ${tool}`,
@@ -57,10 +64,15 @@ export function conversationZhTW(
57
64
  delegationTask: (task: string) => `📋 任務:\n${task}`,
58
65
  delegationDone: (report: string) => `✅ 完成\n\n${report}`,
59
66
  delegationFailed: (error: string) => `❌ 失敗:${error}`,
60
- agentNoReply: (message: string) => `❌ 沒有回覆:${message}`,
61
67
  messageDelivered: (displayName: string, text: string) =>
62
68
  `📨 給 **${displayName}**:\n${text}`,
63
- answerReturned: (text: string) => `↩️ 回覆:\n${text}`,
69
+ intercomSent: (displayName: string) => `→ 給 **${displayName}**`,
70
+ intercomAnswered: (displayName: string) => `← 來自 **${displayName}**`,
71
+ intercomFailed: (displayName: string) => `⚠️ **${displayName}** 沒有回覆`,
72
+ intercomLine: (heading: string, summary: string) =>
73
+ summary ? `${heading}:${summary}` : heading,
74
+ intercomExpand: "展開全文",
75
+ intercomGone: "這則對講的全文已經不在了;夥伴們的對話裡還找得到。",
64
76
  thinkingAuto: "自動",
65
77
  confirmTitle: (asker: string) => `${asker} 要執行這個動作`,
66
78
  holdGeneric: (tool: string) => `執行 ${tool}`,
@@ -51,7 +51,9 @@ export function turnTimeoutEn() {
51
51
  `It reached ${limit(t)} and has worked ${durationEn(t.workedMs)}.`,
52
52
  `Now: ${doing(t)}.`,
53
53
  tools(t),
54
- `It keeps working while this card is open. Continue gives it another ${durationEn(budgetMs)}; Cancel stops it. With no answer in ${durationEn(t.graceMs)} it stops.`,
54
+ t.autoContinue
55
+ ? `It keeps working while this card is open. Continue gives it another ${durationEn(budgetMs)}; Cancel stops it. Yolo mode is on: with no answer in ${durationEn(t.graceMs)} it keeps going by itself.`
56
+ : `It keeps working while this card is open. Continue gives it another ${durationEn(budgetMs)}; Cancel stops it. With no answer in ${durationEn(t.graceMs)} it stops.`,
55
57
  ]
56
58
  .filter(Boolean)
57
59
  .join("\n"),
@@ -64,6 +66,8 @@ export function turnTimeoutEn() {
64
66
  turnTimeoutFinished: "✅ The turn finished; nothing to answer.",
65
67
  turnTimeoutUnanswered: (t: TurnTimeout) =>
66
68
  `⏱️ No answer in ${durationEn(t.graceMs)}; the turn was stopped.`,
69
+ turnTimeoutAutoContinued: (t: TurnTimeout) =>
70
+ `⏱️ No answer in ${durationEn(t.graceMs)}; yolo mode is on, so the turn continued automatically.`,
67
71
  turnTimedOutNotice: (t: TurnTimeout) => {
68
72
  const what =
69
73
  t.running.length > 0
@@ -105,7 +109,9 @@ export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
105
109
  `已達到${limit(t)},已工作 ${durationZh(t.workedMs)}。`,
106
110
  `現在:${doing(t)}。`,
107
111
  tools(t),
108
- `這張卡片開著時它會繼續工作。按「繼續」再給它 ${durationZh(budgetMs)};按「取消」停止。${durationZh(t.graceMs)}內沒有回答就會停止。`,
112
+ t.autoContinue
113
+ ? `這張卡片開著時它會繼續工作。按「繼續」再給它 ${durationZh(budgetMs)};按「取消」停止。Yolo 模式已開啟:${durationZh(t.graceMs)}內沒有回答,它會自己繼續。`
114
+ : `這張卡片開著時它會繼續工作。按「繼續」再給它 ${durationZh(budgetMs)};按「取消」停止。${durationZh(t.graceMs)}內沒有回答就會停止。`,
109
115
  ]
110
116
  .filter(Boolean)
111
117
  .join("\n"),
@@ -117,6 +123,8 @@ export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
117
123
  turnTimeoutFinished: "✅ 這一輪已完成,不需要回答。",
118
124
  turnTimeoutUnanswered: (t: TurnTimeout) =>
119
125
  `⏱️ ${durationZh(t.graceMs)}內沒有回答,這一輪已停止。`,
126
+ turnTimeoutAutoContinued: (t: TurnTimeout) =>
127
+ `⏱️ ${durationZh(t.graceMs)}內沒有回答;Yolo 模式已開啟,這一輪已自動繼續。`,
120
128
  turnTimedOutNotice: (t: TurnTimeout) => {
121
129
  const what =
122
130
  t.running.length > 0
@@ -202,6 +202,11 @@ export type LateAnswer = { by: string } & (
202
202
  */
203
203
  export interface PromptWait {
204
204
  late(answer: LateAnswer): string | undefined;
205
+ /**
206
+ * How long this turn waits before `pending`, in ms, in place of the surface's own grace period;
207
+ * a yolo turn asks with a shorter one. A surface that keeps no grace period ignores it.
208
+ */
209
+ graceMs?: number;
205
210
  }
206
211
 
207
212
  /**
@@ -8,6 +8,16 @@ export interface Autonomy {
8
8
  allow: readonly string[];
9
9
  /** Hold classes or rule names yolo never approves, such as `shell.push` or `tool:repo_push`. */
10
10
  card: readonly string[];
11
+ /**
12
+ * How long an `ask_user` card holds a yolo turn before it goes on with its own best choice, in
13
+ * ms; the card stays open. Default `DEFAULT_YOLO_QUESTION_GRACE_MS`, 30 seconds.
14
+ */
15
+ questionGraceMs?: number;
16
+ /**
17
+ * How long the timeout card waits in a yolo conversation before the turn goes on as if
18
+ * Continue were pressed, in ms. Default `DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS`, one minute.
19
+ */
20
+ timeoutCardGraceMs?: number;
11
21
  };
12
22
  /** The most one long-running turn works in all before it asks or stops, in ms. */
13
23
  longRunningMaxMs: number;
@@ -16,8 +26,19 @@ export interface Autonomy {
16
26
  /** The longest a long-running turn works without a person: four hours. */
17
27
  export const DEFAULT_LONG_RUNNING_MS = 4 * 60 * 60_000;
18
28
 
29
+ /** How long a yolo turn waits on its `ask_user` card before it goes on: 30 seconds. */
30
+ export const DEFAULT_YOLO_QUESTION_GRACE_MS = 30_000;
31
+
32
+ /** How long a yolo turn's timeout card waits before the turn goes on by itself: one minute. */
33
+ export const DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS = 60_000;
34
+
19
35
  /** Yolo approves nothing beyond its built-in classes, and a long-running turn stops at four hours. */
20
36
  export const DEFAULT_AUTONOMY: Autonomy = Object.freeze({
21
- yolo: Object.freeze({ allow: [], card: [] }),
37
+ yolo: Object.freeze({
38
+ allow: [],
39
+ card: [],
40
+ questionGraceMs: DEFAULT_YOLO_QUESTION_GRACE_MS,
41
+ timeoutCardGraceMs: DEFAULT_YOLO_TIMEOUT_CARD_GRACE_MS,
42
+ }),
22
43
  longRunningMaxMs: DEFAULT_LONG_RUNNING_MS,
23
44
  });
@@ -5,6 +5,12 @@ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
5
5
  import type { Logger } from "../log.ts";
6
6
  import type { ChannelKey } from "../sessions.ts";
7
7
  import type { Speaker } from "../speakers.ts";
8
+ import {
9
+ followersOf,
10
+ type ModeOrigins,
11
+ resolveMode,
12
+ type StoredMode,
13
+ } from "./mode-inheritance.ts";
8
14
 
9
15
  /** What a conversation is set to run unattended: both off until an owner switches them on. */
10
16
  export interface ConversationMode {
@@ -15,6 +21,12 @@ export interface ConversationMode {
15
21
  /** The principal who last changed it; absent for a conversation never changed. */
16
22
  changedBy?: string;
17
23
  changedAt?: Date;
24
+ /**
25
+ * Present when the modes follow another conversation's, live: a temp knight's follow the
26
+ * conversation it was made from. `name` is that conversation's, as the agent server tells it;
27
+ * `gone` says it no longer exists, so both modes are off. `set` refuses such a conversation.
28
+ */
29
+ inheritedFrom?: { channel: ChannelKey; name?: string; gone?: true };
18
30
  }
19
31
 
20
32
  /** Which modes a change sets; a mode left out stays as it is. */
@@ -53,7 +65,33 @@ export interface ConversationModes {
53
65
  change: ModeChange,
54
66
  by: Speaker,
55
67
  ): Promise<ConversationMode>;
56
- /** Called after each change. Returns the function that stops it. A throwing listener is logged. */
68
+ /**
69
+ * Makes each of `channels` follow `origin`'s modes live, in place of its own: `peek` returns the
70
+ * origin's, with `inheritedFrom`, and `set` is refused for it. Stored, so it survives a restart.
71
+ * Throws `PluginError` unless `by` is an owner other than the system principal, as `set` does.
72
+ */
73
+ follow(
74
+ channels: readonly ChannelKey[],
75
+ origin: ChannelKey,
76
+ by: Speaker,
77
+ ): Promise<void>;
78
+ /**
79
+ * Drops a conversation's modes and what it follows, as for a conversation that is gone or made
80
+ * anew. It only turns modes off, so it needs no speaker.
81
+ */
82
+ forget(channel: ChannelKey): Promise<void>;
83
+ /**
84
+ * How a following conversation's origin is named and whether it still exists; the agent server
85
+ * gives it. `originOf` is undefined when `channel` no longer follows `origin`, so its own modes
86
+ * count again; `alive: false` when the origin is gone, so both modes read off.
87
+ */
88
+ trackOrigins(origins: {
89
+ originOf(
90
+ channel: ChannelKey,
91
+ origin: ChannelKey,
92
+ ): { name: string; alive: boolean } | undefined;
93
+ }): void;
94
+ /** Called after each change, also for each conversation that follows the changed one. Returns the function that stops it. A throwing listener is logged. */
57
95
  onChange(
58
96
  listener: (channel: ChannelKey, mode: ConversationMode) => void,
59
97
  ): () => void;
@@ -68,27 +106,35 @@ interface Row {
68
106
  long_running: boolean;
69
107
  changed_by: string | null;
70
108
  changed_at: Date;
109
+ follows: string | null;
71
110
  }
72
111
 
73
- const OFF: ConversationMode = Object.freeze({
74
- yolo: false,
75
- longRunning: false,
76
- });
77
-
78
- function modeOf(row: Row): ConversationMode {
79
- return Object.freeze({
80
- yolo: row.yolo,
81
- longRunning: row.long_running,
82
- ...(row.changed_by === null ? {} : { changedBy: row.changed_by }),
83
- changedAt: row.changed_at,
84
- });
112
+ function storedOf(row: Row): StoredMode {
113
+ return {
114
+ own: Object.freeze({
115
+ yolo: row.yolo,
116
+ longRunning: row.long_running,
117
+ ...(row.changed_by === null ? {} : { changedBy: row.changed_by }),
118
+ changedAt: row.changed_at,
119
+ }),
120
+ ...(row.follows === null ? {} : { follows: row.follows as ChannelKey }),
121
+ };
122
+ }
123
+
124
+ /** Refuses a change of modes by anyone but an owner, and by the host's own turns. */
125
+ function checkOwner(by: Speaker): void {
126
+ if (by.tier !== "owner" || by.principalId === SYSTEM_PRINCIPAL)
127
+ throw new PluginError(
128
+ "only an owner sets a conversation's modes, and not the host's own turns",
129
+ );
85
130
  }
86
131
 
87
132
  /** The conversation modes in the `conversation_modes` table, cached in memory after one load. */
88
133
  export class PgConversationModes implements ConversationModes {
89
134
  readonly #sql: SQL;
90
135
  readonly #logger: Logger;
91
- readonly #modes = new Map<ChannelKey, ConversationMode>();
136
+ readonly #modes = new Map<ChannelKey, StoredMode>();
137
+ #origins: ModeOrigins | undefined;
92
138
  readonly #changed = new Set<
93
139
  (channel: ChannelKey, mode: ConversationMode) => void
94
140
  >();
@@ -99,7 +145,7 @@ export class PgConversationModes implements ConversationModes {
99
145
  this.#logger = logger;
100
146
  }
101
147
 
102
- /** The modes' table; the host runs this before the modes attach. */
148
+ /** The modes' table; the host runs this, then `followsMigration`, before the modes attach. */
103
149
  static readonly migration: Migration = {
104
150
  name: "conversation-modes",
105
151
  up: async (sql) => {
@@ -114,6 +160,20 @@ export class PgConversationModes implements ConversationModes {
114
160
  },
115
161
  };
116
162
 
163
+ /** The conversation whose modes a row follows, such as a temp knight's origin. */
164
+ static readonly followsMigration: Migration = {
165
+ name: "conversation-modes-follows",
166
+ up: async (sql) => {
167
+ await sql`ALTER TABLE conversation_modes ADD COLUMN IF NOT EXISTS follows text`;
168
+ },
169
+ };
170
+
171
+ /** Every migration of the modes' table, in order. */
172
+ static readonly migrations: readonly Migration[] = [
173
+ PgConversationModes.migration,
174
+ PgConversationModes.followsMigration,
175
+ ];
176
+
117
177
  /**
118
178
  * The modes over the host's migrated pool, with none loaded: `Roundtable.check()` opens no
119
179
  * connection, so the rows are read by `load`, from the plugin's service start.
@@ -128,11 +188,15 @@ export class PgConversationModes implements ConversationModes {
128
188
  // A mode `set` wrote while the rows were read is newer than its row here.
129
189
  for (const row of rows)
130
190
  if (!this.#modes.has(row.channel as ChannelKey))
131
- this.#modes.set(row.channel as ChannelKey, modeOf(row));
191
+ this.#modes.set(row.channel as ChannelKey, storedOf(row));
132
192
  }
133
193
 
134
194
  peek(channel: ChannelKey): ConversationMode {
135
- return this.#modes.get(channel) ?? OFF;
195
+ return resolveMode(channel, (key) => this.#modes.get(key), this.#origins);
196
+ }
197
+
198
+ trackOrigins(origins: ModeOrigins): void {
199
+ this.#origins = origins;
136
200
  }
137
201
 
138
202
  async set(
@@ -140,9 +204,11 @@ export class PgConversationModes implements ConversationModes {
140
204
  change: ModeChange,
141
205
  by: Speaker,
142
206
  ): Promise<ConversationMode> {
143
- if (by.tier !== "owner" || by.principalId === SYSTEM_PRINCIPAL)
207
+ checkOwner(by);
208
+ const inherited = this.peek(channel).inheritedFrom;
209
+ if (inherited)
144
210
  throw new PluginError(
145
- "only an owner sets a conversation's modes, and not the host's own turns",
211
+ `this conversation follows the modes of ${inherited.name ?? inherited.channel}; change them there`,
146
212
  );
147
213
  const yolo = change.yolo ?? null;
148
214
  const longRunning = change.longRunning ?? null;
@@ -155,13 +221,14 @@ export class PgConversationModes implements ConversationModes {
155
221
  yolo = COALESCE(${yolo}::boolean, conversation_modes.yolo),
156
222
  long_running = COALESCE(${longRunning}::boolean, conversation_modes.long_running),
157
223
  changed_by = ${by.principalId},
158
- changed_at = now()
224
+ changed_at = now(),
225
+ follows = NULL
159
226
  RETURNING *`;
160
227
  const [row] = rows;
161
228
  if (!row)
162
229
  throw new Error(`conversation modes of ${channel} were not stored`);
163
- const mode = modeOf(row);
164
- this.#modes.set(channel, mode);
230
+ this.#modes.set(channel, storedOf(row));
231
+ const mode = this.peek(channel);
165
232
  this.#logger.info(
166
233
  {
167
234
  channel,
@@ -171,10 +238,44 @@ export class PgConversationModes implements ConversationModes {
171
238
  },
172
239
  "conversation modes changed",
173
240
  );
174
- this.#tell(this.#changed, "change", (listener) => listener(channel, mode));
241
+ this.#announce(channel);
175
242
  return mode;
176
243
  }
177
244
 
245
+ async follow(
246
+ channels: readonly ChannelKey[],
247
+ origin: ChannelKey,
248
+ by: Speaker,
249
+ ): Promise<void> {
250
+ checkOwner(by);
251
+ for (const channel of channels) {
252
+ // Its own modes go off: they count again only once it no longer follows.
253
+ const rows: Row[] = await this.#sql`
254
+ INSERT INTO conversation_modes (channel, yolo, long_running, changed_by, changed_at, follows)
255
+ VALUES (${channel}, false, false, ${by.principalId}, now(), ${origin})
256
+ ON CONFLICT (channel) DO UPDATE SET
257
+ yolo = false, long_running = false, changed_by = ${by.principalId},
258
+ changed_at = now(), follows = ${origin}
259
+ RETURNING *`;
260
+ const [row] = rows;
261
+ if (!row)
262
+ throw new Error(`conversation modes of ${channel} were not stored`);
263
+ this.#modes.set(channel, storedOf(row));
264
+ this.#logger.info(
265
+ { channel, origin, by: by.principalId },
266
+ "conversation modes follow another conversation",
267
+ );
268
+ this.#announce(channel);
269
+ }
270
+ }
271
+
272
+ async forget(channel: ChannelKey): Promise<void> {
273
+ await this.#sql`DELETE FROM conversation_modes WHERE channel = ${channel}`;
274
+ if (!this.#modes.delete(channel)) return;
275
+ this.#logger.info({ channel }, "conversation modes forgotten");
276
+ this.#announce(channel);
277
+ }
278
+
178
279
  onChange(
179
280
  listener: (channel: ChannelKey, mode: ConversationMode) => void,
180
281
  ): () => void {
@@ -200,6 +301,17 @@ export class PgConversationModes implements ConversationModes {
200
301
  return () => void this.#approved.delete(listener);
201
302
  }
202
303
 
304
+ /** Tells the listeners of `channel`'s modes, and of each conversation that follows it. */
305
+ #announce(channel: ChannelKey): void {
306
+ for (const key of [
307
+ channel,
308
+ ...followersOf(channel, this.#modes, this.#origins),
309
+ ]) {
310
+ const mode = this.peek(key);
311
+ this.#tell(this.#changed, "change", (listener) => listener(key, mode));
312
+ }
313
+ }
314
+
203
315
  /** Runs each listener; one that throws is logged and the rest still hear it. */
204
316
  #tell<L>(listeners: Set<L>, what: string, call: (listener: L) => void): void {
205
317
  for (const listener of [...listeners]) {