dsh-bot-team 1.1.0 → 1.3.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.
@@ -0,0 +1,213 @@
1
+ /** Bot-specific answerers for the public Host human-interaction waterfalls. */
2
+ import { newId } from './util.js';
3
+ import { currentConversationId, isCurrentMemberSession } from './conversations.js';
4
+
5
+ const questionError = (code) => Object.assign(new Error(code), { name: 'UserQuestionError', code });
6
+ const invalid = (message) => Object.assign(new Error(message), { code: 'invalid-interaction' });
7
+ const clone = (value) => JSON.parse(JSON.stringify(value));
8
+
9
+ /** Validate complete native QuestionAnswer batches; empty selections explicitly skip a question. */
10
+ export function validateBackgroundAnswer(questions, answer) {
11
+ if (!answer || !Array.isArray(answer.answers) || answer.answers.length !== questions.length) throw invalid('请提交每个问题的答案。');
12
+ const remaining = new Map(questions.map(question => [question.id, question]));
13
+ const answers = answer.answers.map(item => {
14
+ const question = item && remaining.get(item.id);
15
+ if (!question || !Array.isArray(item.selected) || item.selected.some(label => typeof label !== 'string')) throw invalid('问题或答案格式不正确。');
16
+ remaining.delete(item.id);
17
+ const labels = new Set((question.options || []).map(option => option.label));
18
+ if (new Set(item.selected).size !== item.selected.length || item.selected.some(label => !labels.has(label))) throw invalid('答案包含无效或重复的选项。');
19
+ if (question.multiSelect !== true && item.selected.length > 1) throw invalid('此问题只能选择一个选项。');
20
+ if (item.custom !== undefined && typeof item.custom !== 'string') throw invalid('补充答案格式不正确。');
21
+ return { id: item.id, selected: [...item.selected], ...(item.custom === undefined ? {} : { custom: item.custom }) };
22
+ });
23
+ if (remaining.size) throw invalid('请提交每个问题的答案。');
24
+ return { answers };
25
+ }
26
+
27
+ export class BackgroundInteractions {
28
+ constructor({ ctx, repo, bus }) {
29
+ this.ctx = ctx;
30
+ this.repo = repo;
31
+ this.bus = bus;
32
+ this.entries = new Map();
33
+ this.revisions = new Map();
34
+ this.epoch = newId('interactions');
35
+ this.disposed = false;
36
+ this.responding = new Set();
37
+ const options = { global: true, prepend: true };
38
+ this.off = [
39
+ ctx.on('approval/request', (request, next) => this.receive('approval', request, next), options),
40
+ ctx.on('user-questions/request', (request, next) => this.receive('question', request, next), options),
41
+ ctx.on('session/event', session => {
42
+ if ([...repo.members.values()].some(member => member.sessionId === session.id)) this.reconcile();
43
+ }, { global: true }),
44
+ ];
45
+ // Membership removal and Agent disposal need not append a Session event.
46
+ this.timer = setInterval(() => this.reconcile(), 1000);
47
+ this.timer.unref?.();
48
+ }
49
+
50
+ memberFor(agent) {
51
+ const agents = this.ctx.agents;
52
+ if (!agent || !agents || agents.get(agent.id) !== agent || !agents.roots().includes(agent) || agent.session?.id !== agent.id) return;
53
+ const members = [...this.repo.members.values()].filter(member => isCurrentMemberSession(this.repo, member, agent.id) && this.repo.groups.has(member.groupId));
54
+ return members.length === 1 ? members[0] : undefined;
55
+ }
56
+
57
+ owns(entry) {
58
+ const member = this.memberFor(entry.agent);
59
+ return member?.membershipId === entry.view.membershipId && member.groupId === entry.view.groupId && member.botId === entry.view.botId && currentConversationId(this.repo, member.groupId) === entry.view.conversationId;
60
+ }
61
+
62
+ projection(agent) {
63
+ return this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions')?.questions;
64
+ }
65
+
66
+ make(kind, agent, member, fields) {
67
+ const entry = { agent, view: {
68
+ interactionId: newId('interaction'), groupId: member.groupId, sessionId: agent.id,
69
+ membershipId: member.membershipId, botId: member.botId, conversationId: currentConversationId(this.repo, member.groupId),
70
+ botName: this.repo.bots?.get(member.botId)?.name || 'Bot',
71
+ kind, state: 'pending', createdAt: Date.now(), ...fields,
72
+ } };
73
+ this.entries.set(entry.view.interactionId, entry);
74
+ return entry;
75
+ }
76
+
77
+ receive(kind, request, next) {
78
+ const member = !this.disposed && this.memberFor(request.agent);
79
+ if (!member) return next();
80
+ const fields = kind === 'approval'
81
+ ? { toolName: request.toolName, reason: request.reason || '', ...(request.callId ? { callId: request.callId } : {}) }
82
+ : { questions: clone(request.questions), timed: request.wait?.timed === true, ...(request.wait?.callId ? { callId: request.wait.callId } : {}) };
83
+ const entry = this.make(kind, request.agent, member, fields);
84
+ return new Promise((resolve, reject) => {
85
+ entry.resolve = resolve;
86
+ entry.reject = reject;
87
+ const abort = () => {
88
+ if (kind === 'question' && entry.view.timed && request.signal?.reason?.code === 'ASK_TIMED_OUT') this.finish(entry, 'continue');
89
+ else this.finish(entry, 'abort');
90
+ };
91
+ entry.cleanup = () => request.signal?.removeEventListener('abort', abort);
92
+ request.signal?.addEventListener('abort', abort, { once: true });
93
+ if (request.signal?.aborted) abort();
94
+ else this.publish(member.groupId);
95
+ });
96
+ }
97
+
98
+ finish(entry, operation, value) {
99
+ if (!this.entries.has(entry.view.interactionId)) return;
100
+ const { resolve, reject } = entry;
101
+ entry.cleanup?.();
102
+ entry.cleanup = entry.resolve = entry.reject = undefined;
103
+ if (operation === 'continue') {
104
+ entry.view.state = 'continuing';
105
+ entry.transitionAt = Date.now();
106
+ reject?.(questionError('ASK_TIMED_OUT'));
107
+ } else {
108
+ this.entries.delete(entry.view.interactionId);
109
+ if (entry.view.kind === 'approval') resolve?.(operation === 'answer' ? value : 'cancelled');
110
+ else if (operation === 'answer') resolve?.(value);
111
+ else reject?.(questionError(operation === 'cancel' ? 'ASK_CANCELLED' : 'ASK_ABORTED'));
112
+ }
113
+ this.publish(entry.view.groupId);
114
+ }
115
+
116
+ /** Reconcile against public Session projections, including late questions after a reload. */
117
+ reconcile() {
118
+ if (this.disposed || this.reconciling) return;
119
+ this.reconciling = true;
120
+ const changed = new Set();
121
+ try {
122
+ for (const entry of [...this.entries.values()]) {
123
+ if (!this.owns(entry)) { this.finish(entry, 'abort'); continue; }
124
+ if (entry.view.kind !== 'question' || entry.view.state === 'pending') continue;
125
+ const projection = this.projection(entry.agent);
126
+ const active = projection?.active?.find(question => question.callId === entry.view.callId);
127
+ if (active?.state === 'continued') {
128
+ if (entry.view.state === 'continuing') { entry.view.state = 'continued'; changed.add(entry.view.groupId); }
129
+ } else if (entry.view.state !== 'continuing' || projection?.settled?.some(question => question.callId === entry.view.callId) || Date.now() - entry.transitionAt > 30000) {
130
+ this.entries.delete(entry.view.interactionId);
131
+ changed.add(entry.view.groupId);
132
+ }
133
+ }
134
+ for (const member of this.repo.members.values()) {
135
+ const agent = this.ctx.agents?.get(member.sessionId);
136
+ if (this.memberFor(agent)?.membershipId !== member.membershipId) continue;
137
+ for (const question of this.projection(agent)?.active || []) {
138
+ if (question.state !== 'continued' || [...this.entries.values()].some(entry => entry.agent === agent && entry.view.callId === question.callId)) continue;
139
+ this.make('question', agent, member, { questions: clone(question.questions), callId: question.callId, timed: true, state: 'continued' });
140
+ changed.add(member.groupId);
141
+ }
142
+ }
143
+ } finally { this.reconciling = false; }
144
+ for (const groupId of changed) this.publish(groupId);
145
+ }
146
+
147
+ view(groupId) {
148
+ return { groupId, epoch: this.epoch, revision: this.revisions.get(groupId) || 0,
149
+ interactions: [...this.entries.values()].filter(entry => entry.view.groupId === groupId && this.owns(entry)).map(entry => clone(entry.view)) };
150
+ }
151
+
152
+ publish(groupId) {
153
+ this.revisions.set(groupId, (this.revisions.get(groupId) || 0) + 1);
154
+ this.bus.emit(groupId, { type: 'interaction.updated', ...this.view(groupId) });
155
+ }
156
+
157
+ snapshot(groupId) {
158
+ if (typeof groupId !== 'string' || !this.repo.groups.has(groupId)) throw invalid('群聊不存在。');
159
+ this.reconcile();
160
+ return this.view(groupId);
161
+ }
162
+
163
+ async respond(input = {}) {
164
+ this.reconcile();
165
+ const entry = this.entries.get(input.interactionId);
166
+ if (this.disposed || !entry || !this.owns(entry) || entry.view.groupId !== input.groupId || entry.view.sessionId !== input.sessionId) throw invalid('此交互已失效或不属于当前会话。');
167
+ const { view } = entry;
168
+ if (input.expectedConversationId !== undefined && input.expectedConversationId !== view.conversationId) throw invalid('此交互已失效或不属于当前会话。');
169
+ this.assertAdmission?.(view.groupId);
170
+ if (entry.busy || view.state === 'continuing' || view.state === 'queued') throw invalid('此答案正在处理,请稍后刷新。');
171
+ if (input.operation === 'continue') {
172
+ if (view.kind !== 'question' || !view.timed || view.state !== 'pending') throw invalid('此交互不能稍后回答。');
173
+ this.finish(entry, 'continue');
174
+ } else if (input.operation === 'cancel') {
175
+ if (view.state !== 'pending') throw invalid('此问题已继续执行,请提交答案或跳过。');
176
+ this.finish(entry, 'cancel');
177
+ } else if (input.operation === 'answer') {
178
+ if (view.kind === 'approval') {
179
+ if (!['allowed-once', 'rejected'].includes(input.outcome)) throw invalid('审批决定无效。');
180
+ this.finish(entry, 'answer', input.outcome);
181
+ } else {
182
+ const answer = validateBackgroundAnswer(view.questions, input.answer);
183
+ if (view.state === 'pending') this.finish(entry, 'answer', answer);
184
+ else {
185
+ entry.busy = true;
186
+ this.responding.add(entry);
187
+ try {
188
+ const questions = this.ctx.get('userQuestions');
189
+ if (typeof questions?.answer !== 'function') throw invalid('提问服务暂不可用,请稍后重试。');
190
+ const accepted = await questions.answer(entry.agent, view.callId, answer);
191
+ if (!this.owns(entry)) throw invalid('此交互已失效或不属于当前会话。');
192
+ if (!accepted) { this.entries.delete(view.interactionId); throw invalid('此问题已失效,请刷新。'); }
193
+ view.state = 'queued';
194
+ } catch (error) {
195
+ if (error?.code !== 'REPLY_QUEUED') throw error;
196
+ if (!this.owns(entry)) throw invalid('此交互已失效或不属于当前会话。');
197
+ view.state = 'queued';
198
+ } finally { entry.busy = false; this.responding.delete(entry); this.publish(view.groupId); }
199
+ }
200
+ }
201
+ } else throw invalid('未知的交互操作。');
202
+ return { accepted: true, ...this.snapshot(view.groupId) };
203
+ }
204
+
205
+ dispose() {
206
+ if (this.disposed) return;
207
+ this.disposed = true;
208
+ clearInterval(this.timer);
209
+ for (const off of this.off) off?.();
210
+ for (const entry of [...this.entries.values()]) this.finish(entry, 'abort');
211
+ this.entries.clear();
212
+ }
213
+ }
@@ -0,0 +1,45 @@
1
+ /** Pure conversation checks shared by execution, history, and service code. */
2
+ const groupOf = (repo, groupId) => repo?.getGroup?.(groupId) ?? repo?.groups?.get?.(groupId);
3
+ const memberOf = (repo, memberId) => repo?.getMember?.(memberId) ?? repo?.members?.get?.(memberId);
4
+
5
+ export function currentConversationId(repo, groupId) {
6
+ if (typeof repo?.activeConversation === 'function') return repo.activeConversation(groupId)?.conversationId ?? null;
7
+ return groupOf(repo, groupId)?.activeConversationId ?? null;
8
+ }
9
+
10
+ export function conversationFloor(repo, groupId) {
11
+ const group = groupOf(repo, groupId);
12
+ if (typeof repo?.activeConversation === 'function') {
13
+ const conversation = repo.activeConversation(groupId);
14
+ if ((!conversation && group) || (conversation && (!Number.isSafeInteger(conversation.afterSeq) || conversation.afterSeq < 0 ||
15
+ (group?.activeConversationId && group.activeConversationId !== conversation.conversationId)))) {
16
+ throw Object.assign(new Error('聊天会话边界不可用'), { code: 'conversation-corrupt' });
17
+ }
18
+ return conversation?.afterSeq ?? 0;
19
+ }
20
+ // Only legacy doubles with no generation pointer can use the legacy floor.
21
+ if (group?.activeConversationId) throw Object.assign(new Error('聊天会话边界不可用'), { code: 'conversation-corrupt' });
22
+ return 0;
23
+ }
24
+
25
+ export function conversationIdOf(repo, record) {
26
+ if (!record) return null;
27
+ if (typeof repo?.conversationFor === 'function') return repo.conversationFor(record)?.conversationId ?? null;
28
+ // An untagged record is legacy history, never implicitly the new current turn.
29
+ return record.conversationId ?? null;
30
+ }
31
+
32
+ export function isCurrentMemberSession(repo, member, sessionId = member?.sessionId) {
33
+ if (!member || !sessionId || member.sessionId !== sessionId) return false;
34
+ const latest = memberOf(repo, member.membershipId);
35
+ if ((typeof repo?.getMember === 'function' || typeof repo?.members?.get === 'function') &&
36
+ (!latest || latest.groupId !== member.groupId || latest.botId !== member.botId || latest.sessionId !== sessionId)) return false;
37
+ const conversationId = currentConversationId(repo, member.groupId);
38
+ const strict = typeof repo?.activeConversation === 'function' || Boolean(groupOf(repo, member.groupId)?.activeConversationId);
39
+ if (strict && (!conversationId || member.sessionConversationId !== conversationId || (latest && latest.sessionConversationId !== conversationId))) return false;
40
+ if (typeof repo?.sessionBindingFor === 'function') {
41
+ const binding = repo.sessionBindingFor(sessionId);
42
+ if (!binding || binding.groupId !== member.groupId || binding.membershipId !== member.membershipId || binding.botId !== member.botId || binding.conversationId !== conversationId) return false;
43
+ }
44
+ return true;
45
+ }
@@ -0,0 +1,179 @@
1
+ /** Pure validation and step planning for version 2 discussions. */
2
+ export const MAX_DISCUSSION_ROUNDS = 20;
3
+
4
+ export const DISCUSSION_ACTIVE_STATUSES = Object.freeze([
5
+ 'running', 'pausing', 'paused', 'finalizing', 'stopping',
6
+ ]);
7
+
8
+ export function isDiscussionActive(record) {
9
+ return Boolean(record && DISCUSSION_ACTIVE_STATUSES.includes(record.status));
10
+ }
11
+
12
+ function invalid(message) {
13
+ throw Object.assign(new Error(message), { code: 'invalid-input' });
14
+ }
15
+
16
+ function plainObject(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value) &&
18
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
19
+ }
20
+
21
+ function text(value, name, { required = false, max = Infinity } = {}) {
22
+ if (value === undefined && !required) return '';
23
+ if (typeof value !== 'string') invalid(name + ' 必须是字符串');
24
+ const result = value.trim();
25
+ if (required && !result) invalid(name + ' 不能为空');
26
+ if (value.length > max) invalid(name + ' 不能超过 ' + max + ' 字');
27
+ return result;
28
+ }
29
+
30
+ function choice(value, name, values, fallback) {
31
+ if (value === undefined) return fallback;
32
+ if (!values.includes(value)) invalid(name + ' 无效');
33
+ return value;
34
+ }
35
+
36
+ function ids(value, name) {
37
+ if (!Array.isArray(value)) invalid(name + ' 必须是数组');
38
+ const result = value.map(item => text(item, name, { required: true }));
39
+ if (new Set(result).size !== result.length) invalid(name + ' 不能重复');
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Validate start input without mutating repository state or capturing attachments.
45
+ * memberIds includes every assigned participant; speakerIds contains only speakers
46
+ * in the regular rounds. The engine captures contextMessageIds via captureQuote.
47
+ */
48
+ export function normalizeDiscussionInput(repo, input) {
49
+ if (!plainObject(input)) invalid('讨论参数必须是对象');
50
+ const groupId = text(input.groupId, 'groupId', { required: true });
51
+ const group = repo.getGroup(groupId);
52
+ if (!group) throw Object.assign(new Error('群不存在'), { code: 'not-found' });
53
+ if (group.kind === 'direct') invalid('单聊不能开始多人讨论或辩论');
54
+
55
+ const mode = choice(input.mode, 'mode', ['discussion', 'debate'], 'discussion');
56
+ const format = choice(input.format, 'format', ['classic', 'structured'], 'classic');
57
+ const topic = text(input.topic, '议题', { required: true, max: 2000 });
58
+ const background = text(input.background, '背景', { max: 12000 });
59
+ const goal = text(input.goal, '目标', { max: 2000 });
60
+ const responseLength = choice(input.responseLength, 'responseLength', ['short', 'standard', 'detailed'], 'standard');
61
+ const language = choice(input.language, 'language', ['auto', 'zh', 'en'], 'auto');
62
+ if (input.summarize !== undefined && typeof input.summarize !== 'boolean') invalid('summarize 必须是布尔值');
63
+ const summarize = input.summarize === undefined ? mode === 'discussion' && format === 'structured' : input.summarize;
64
+ const minRounds = mode === 'discussion' && format === 'structured' ? 2 : 1;
65
+ const rounds = input.rounds === undefined ? (minRounds === 2 ? 3 : 2) : input.rounds;
66
+ if (!Number.isInteger(rounds) || rounds < minRounds || rounds > MAX_DISCUSSION_ROUNDS) {
67
+ invalid('rounds 必须是 ' + minRounds + ' 到 ' + MAX_DISCUSSION_ROUNDS + ' 之间的整数');
68
+ }
69
+ const expectedConversationId = input.expectedConversationId === undefined ? undefined
70
+ : text(input.expectedConversationId, 'expectedConversationId', { required: true });
71
+
72
+ const eligible = member => member && member.groupId === groupId && member.enabled !== false && repo.getBot(member.botId);
73
+ const requireMember = membershipId => {
74
+ const member = repo.getMember(membershipId);
75
+ if (!eligible(member)) invalid('成员不存在、不属于本群、已停用或其 Bot 已删除:' + membershipId);
76
+ return member;
77
+ };
78
+ const role = name => input[name] === undefined || input[name] === null || input[name] === '' ? null
79
+ : text(input[name], name, { required: true });
80
+ let summaryMembershipId = role('summaryMembershipId');
81
+ const proMembershipId = role('proMembershipId');
82
+ const conMembershipId = role('conMembershipId');
83
+ const judgeMembershipId = role('judgeMembershipId');
84
+ const proPosition = text(input.proPosition, '正方立场', { max: 2000 });
85
+ const conPosition = text(input.conPosition, '反方立场', { max: 2000 });
86
+ let speakerIds;
87
+
88
+ if (mode === 'debate') {
89
+ if (!proMembershipId || !conMembershipId || proMembershipId === conMembershipId) invalid('辩论必须选择不同的正方和反方成员');
90
+ if (judgeMembershipId === proMembershipId || judgeMembershipId === conMembershipId) invalid('裁判必须独立于正方和反方');
91
+ if (summaryMembershipId) invalid('辩论请使用 judgeMembershipId 指定裁判');
92
+ speakerIds = [proMembershipId, conMembershipId];
93
+ if (input.membershipIds !== undefined) {
94
+ const selected = ids(input.membershipIds, 'membershipIds');
95
+ if (selected.some(id => !speakerIds.includes(id) && id !== judgeMembershipId) ||
96
+ speakerIds.some(id => !selected.includes(id))) invalid('辩论成员必须包含正反双方,且只能额外包含指定裁判');
97
+ }
98
+ } else {
99
+ if (proMembershipId || conMembershipId || judgeMembershipId || proPosition || conPosition) invalid('正反方和裁判配置仅适用于辩论');
100
+ speakerIds = input.membershipIds === undefined
101
+ ? repo.membersOf(groupId).filter(eligible).map(member => member.membershipId)
102
+ : ids(input.membershipIds, 'membershipIds');
103
+ if (speakerIds.length < (format === 'structured' ? 2 : 1)) invalid(format === 'structured' ? '结构化讨论至少需要两名成员' : '请选择至少一个讨论成员');
104
+ summaryMembershipId ??= speakerIds[0];
105
+ }
106
+ if (new Set(speakerIds).size !== speakerIds.length) invalid('讨论成员不能重复');
107
+ const memberIds = [...new Set([...speakerIds, summaryMembershipId, judgeMembershipId].filter(Boolean))];
108
+ const participants = memberIds.map(membershipId => {
109
+ const member = requireMember(membershipId);
110
+ const bot = repo.getBot(member.botId);
111
+ return { membershipId, botId: member.botId, name: bot.name };
112
+ });
113
+ if (new Set(participants.map(participant => participant.botId)).size !== participants.length) invalid('同一个 Bot 不能承担多个参与者身份');
114
+
115
+ const contextMessageIds = input.contextMessageIds === undefined ? [] : ids(input.contextMessageIds, 'contextMessageIds');
116
+ if (contextMessageIds.length > 20) invalid('引用消息最多 20 条');
117
+ for (const messageId of contextMessageIds) {
118
+ const message = repo.getMessage(messageId);
119
+ if (!message || message.groupId !== groupId) invalid('引用消息不存在或不属于本群:' + messageId);
120
+ }
121
+ const perspectiveInput = input.perspectives === undefined ? {} : input.perspectives;
122
+ if (!plainObject(perspectiveInput)) invalid('perspectives 必须是成员到视角文本的对象');
123
+ const perspectives = Object.fromEntries(Object.entries(perspectiveInput).map(([membershipId, value]) => {
124
+ if (!memberIds.includes(membershipId)) invalid('视角只能分配给本次参与者:' + membershipId);
125
+ return [membershipId, text(value, '成员视角', { max: 500 })];
126
+ }));
127
+
128
+ return {
129
+ protocolVersion: 2, groupId, topic, mode, format, goal, background, rounds, summarize,
130
+ memberIds, speakerIds, participants, summaryMembershipId,
131
+ proMembershipId, conMembershipId, judgeMembershipId, proPosition, conPosition,
132
+ responseLength, language, perspectives, contextMessageIds, expectedConversationId,
133
+ };
134
+ }
135
+
136
+ /** Build a deterministic plan; every call returns fresh mutable step state. */
137
+ export function createDiscussionPlan(config) {
138
+ const steps = [];
139
+ const add = (phase, membershipId, round, side = null, targetStepId) => {
140
+ const step = {
141
+ stepId: 'step-' + (steps.length + 1), phase, membershipId, side, round,
142
+ ...(targetStepId ? { targetStepId } : {}),
143
+ status: 'pending', attempts: [], messageIds: [],
144
+ };
145
+ steps.push(step);
146
+ return step;
147
+ };
148
+ if (config.mode === 'debate') {
149
+ const members = { pro: config.proMembershipId, con: config.conMembershipId };
150
+ const latestStatement = {
151
+ pro: add('opening', members.pro, 1, 'pro'),
152
+ con: add('opening', members.con, 1, 'con'),
153
+ };
154
+ for (let round = 1; round <= config.rounds; round += 1) {
155
+ const order = round % 2 === 1 ? ['pro', 'con'] : ['con', 'pro'];
156
+ for (const side of order) {
157
+ const other = side === 'pro' ? 'con' : 'pro';
158
+ const question = add('question', members[side], round, side, latestStatement[other].stepId);
159
+ latestStatement[other] = add('answer', members[other], round, other, question.stepId);
160
+ }
161
+ }
162
+ add('closing', members.pro, config.rounds, 'pro');
163
+ add('closing', members.con, config.rounds, 'con');
164
+ if (config.judgeMembershipId) add('judgement', config.judgeMembershipId, config.rounds);
165
+ return steps;
166
+ }
167
+
168
+ const speakerIds = config.speakerIds;
169
+ for (let round = 1; round <= config.rounds; round += 1) {
170
+ const phase = config.format !== 'structured' ? 'discussion'
171
+ : round === 1 ? 'viewpoint' : round === config.rounds ? 'converge' : 'challenge';
172
+ for (const membershipId of speakerIds) add(phase, membershipId, round);
173
+ if (config.format === 'structured' && round < config.rounds) {
174
+ add('checkpoint', config.summaryMembershipId, round);
175
+ }
176
+ }
177
+ if (config.summarize) add('summary', config.summaryMembershipId, config.rounds);
178
+ return steps;
179
+ }