pi-roundtable 0.27.0 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +2 -0
- package/README.zh-TW.md +2 -0
- package/docs/plugins.md +29 -13
- package/package.json +1 -1
- package/src/characters/compose.ts +11 -0
- package/src/characters/parts-face.ts +23 -2
- package/src/core/agents/agent-messages.ts +32 -85
- package/src/core/agents/agent-ports.ts +3 -0
- package/src/core/agents/agent-prompt.ts +2 -16
- package/src/core/agents/agent-tools.ts +1 -1
- package/src/core/agents/helper-create.ts +3 -24
- package/src/core/agents/helper-modes.ts +64 -0
- package/src/core/agents/team-turn-types.ts +7 -13
- package/src/core/agents/team-turns.ts +7 -2
- package/src/core/builtin/agent-server.ts +3 -4
- package/src/core/builtin/discord.ts +35 -8
- package/src/core/config/adapters.ts +2 -1
- package/src/core/config/limits.ts +35 -2
- package/src/core/discord/agent-discord.ts +12 -1
- package/src/core/discord/card-copies.ts +7 -2
- package/src/core/discord/discord-surface.ts +23 -38
- package/src/core/discord/intercom-cards.ts +121 -0
- package/src/core/discord/owner-card-asks.ts +9 -0
- package/src/core/discord/owner-card-types.ts +2 -0
- package/src/core/discord/owner-cards.ts +2 -1
- package/src/core/discord/stop-button.ts +35 -0
- package/src/core/domain/conversation.ts +18 -0
- package/src/core/domain/interim.ts +7 -0
- package/src/core/domain/turn-timeout.ts +5 -0
- package/src/core/i18n/conversation.ts +16 -4
- package/src/core/i18n/turn-timeout.ts +10 -2
- package/src/core/interactions/prompts.ts +5 -0
- package/src/core/modes/autonomy.ts +22 -1
- package/src/core/modes/conversation-modes.ts +135 -23
- package/src/core/modes/mode-inheritance.ts +78 -0
- package/src/core/modes/modes-plugin.ts +1 -1
- package/src/core/modules/skills/skill-contract.ts +2 -0
- package/src/core/presentation/intercom.ts +44 -0
- package/src/core/runtime/ask-questions.ts +21 -4
- package/src/core/runtime/extensions/ask-user-text.ts +36 -3
- package/src/core/runtime/extensions/ask-user.ts +21 -1
- package/src/core/runtime/interim-text.ts +7 -4
- package/src/core/runtime/pi-agent-runtime.ts +2 -2
- package/src/core/runtime/prompt-slot.ts +11 -1
- package/src/core/runtime/session-factory.ts +11 -6
- package/src/core/runtime/turn-budget.ts +34 -3
- package/src/core/runtime/turn-limits.ts +13 -3
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
/**
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
207
|
+
checkOwner(by);
|
|
208
|
+
const inherited = this.peek(channel).inheritedFrom;
|
|
209
|
+
if (inherited)
|
|
144
210
|
throw new PluginError(
|
|
145
|
-
|
|
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
|
-
|
|
164
|
-
this
|
|
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.#
|
|
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]) {
|