@rrr2010/opencode-roundtable 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1032 @@
1
+ // index.ts
2
+ import { tool } from "@opencode-ai/plugin";
3
+
4
+ // src/config.ts
5
+ import { readFile, writeFile } from "fs/promises";
6
+ import { join } from "path";
7
+ import os from "os";
8
+ var configDir = (() => {
9
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
10
+ if (xdgConfigHome)
11
+ return join(xdgConfigHome, "opencode");
12
+ return join(os.homedir(), ".config", "opencode");
13
+ })();
14
+ var configPath = join(configDir, "roundtable.json");
15
+ var configSchemaUrl = "https://raw.githubusercontent.com/opencode-ai/roundtable/main/docs/roundtable.schema.json";
16
+ var DEFAULT_CONFIG = {
17
+ defaultTimeoutMs: 300000,
18
+ loopSimilarityThreshold: 0.85,
19
+ toolOutputPreviewMax: 500,
20
+ defaultObserverPrompt: [
21
+ "You are an impartial roundtable observer.",
22
+ "Consolidate the debate below into:",
23
+ "",
24
+ "1. **Executive summary** (2-3 sentences)",
25
+ "2. **Key points** raised by each participant",
26
+ "3. **Decisions or convergences** reached",
27
+ "4. **Remaining open questions**",
28
+ "5. **Suggested next steps**"
29
+ ].join(`
30
+ `),
31
+ maxRounds: 10
32
+ };
33
+ var config = { ...DEFAULT_CONFIG };
34
+ function getConfig() {
35
+ return config;
36
+ }
37
+ function validateConfig(raw) {
38
+ return {
39
+ defaultTimeoutMs: typeof raw.defaultTimeoutMs === "number" && raw.defaultTimeoutMs >= 30000 ? raw.defaultTimeoutMs : DEFAULT_CONFIG.defaultTimeoutMs,
40
+ loopSimilarityThreshold: typeof raw.loopSimilarityThreshold === "number" && raw.loopSimilarityThreshold >= 0 && raw.loopSimilarityThreshold <= 1 ? raw.loopSimilarityThreshold : DEFAULT_CONFIG.loopSimilarityThreshold,
41
+ toolOutputPreviewMax: typeof raw.toolOutputPreviewMax === "number" && raw.toolOutputPreviewMax >= 100 ? raw.toolOutputPreviewMax : DEFAULT_CONFIG.toolOutputPreviewMax,
42
+ defaultObserverPrompt: typeof raw.defaultObserverPrompt === "string" && raw.defaultObserverPrompt.length > 0 ? raw.defaultObserverPrompt : DEFAULT_CONFIG.defaultObserverPrompt,
43
+ maxRounds: typeof raw.maxRounds === "number" && raw.maxRounds >= 1 ? raw.maxRounds : DEFAULT_CONFIG.maxRounds
44
+ };
45
+ }
46
+ async function loadConfig(ctx) {
47
+ try {
48
+ const content = await readFile(configPath, "utf-8");
49
+ const raw = JSON.parse(content);
50
+ if (typeof raw !== "object" || !raw)
51
+ throw new Error("Invalid config format");
52
+ config = validateConfig(raw);
53
+ await ctx.client.app.log({
54
+ body: {
55
+ service: "roundtable",
56
+ level: "info",
57
+ message: "Configuration loaded",
58
+ extra: { configPath }
59
+ }
60
+ });
61
+ } catch (err) {
62
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
63
+ try {
64
+ await writeFile(configPath, JSON.stringify({ $schema: configSchemaUrl, ...DEFAULT_CONFIG }, null, 2), "utf-8");
65
+ config = { ...DEFAULT_CONFIG };
66
+ await ctx.client.app.log({
67
+ body: {
68
+ service: "roundtable",
69
+ level: "info",
70
+ message: "Created default configuration",
71
+ extra: { configPath }
72
+ }
73
+ });
74
+ } catch {
75
+ config = { ...DEFAULT_CONFIG };
76
+ }
77
+ } else {
78
+ try {
79
+ await ctx.client.app.log({
80
+ body: {
81
+ service: "roundtable",
82
+ level: "warn",
83
+ message: `Invalid config, using defaults: ${err instanceof Error ? err.message : String(err)}`,
84
+ extra: { configPath }
85
+ }
86
+ });
87
+ } catch {}
88
+ config = { ...DEFAULT_CONFIG };
89
+ }
90
+ }
91
+ }
92
+
93
+ // src/state.ts
94
+ var states = new Map;
95
+ var timeoutHandles = new Map;
96
+ var pendingResults = new Map;
97
+ function serializeState(state) {
98
+ const json = JSON.stringify(state);
99
+ return `[ROUNDTABLE META — INTERNAL ORCHESTRATION DATA — IGNORE FOR ANALYSIS] ${json} [/ROUNDTABLE META]`;
100
+ }
101
+ function findSerializedState(messages) {
102
+ let last = null;
103
+ for (const msg of messages) {
104
+ if (msg.info.role !== "user")
105
+ continue;
106
+ for (const part of msg.parts) {
107
+ if (part.type === "text" && part.text.startsWith("[ROUNDTABLE META")) {
108
+ last = part.text;
109
+ }
110
+ }
111
+ }
112
+ return last;
113
+ }
114
+ function deserializeState(raw) {
115
+ try {
116
+ const endTag = "[/ROUNDTABLE META]";
117
+ const startIdx = raw.indexOf("[ROUNDTABLE META");
118
+ if (startIdx === -1)
119
+ return null;
120
+ const closeBracket = raw.indexOf("]", startIdx);
121
+ if (closeBracket === -1)
122
+ return null;
123
+ const contentStart = closeBracket + 1;
124
+ const endIdx = raw.indexOf(endTag, contentStart);
125
+ if (endIdx === -1)
126
+ return null;
127
+ const json = raw.slice(contentStart, endIdx).trim();
128
+ if (!json)
129
+ return null;
130
+ const parsed = JSON.parse(json);
131
+ if (typeof parsed !== "object" || !parsed)
132
+ return null;
133
+ if (typeof parsed.sessionID !== "string")
134
+ return null;
135
+ if (typeof parsed.parentSessionID !== "string")
136
+ return null;
137
+ if (!Array.isArray(parsed.agents))
138
+ return null;
139
+ if (typeof parsed.totalRounds !== "number")
140
+ return null;
141
+ if (typeof parsed.prompt !== "string")
142
+ return null;
143
+ return {
144
+ sessionID: parsed.sessionID,
145
+ parentSessionID: parsed.parentSessionID,
146
+ agents: parsed.agents,
147
+ totalRounds: parsed.totalRounds,
148
+ observer: parsed.observer ?? "built-in",
149
+ prompt: parsed.prompt,
150
+ currentRound: parsed.currentRound ?? 0,
151
+ currentAgentIndex: parsed.currentAgentIndex ?? 0,
152
+ phase: parsed.phase ?? "done",
153
+ history: parsed.history ?? [],
154
+ errors: parsed.errors ?? [],
155
+ createdAt: parsed.createdAt ?? 0,
156
+ currentGeneration: parsed.currentGeneration ?? 0,
157
+ userInterjections: parsed.userInterjections ?? [],
158
+ lastProcessedMsgId: parsed.lastProcessedMsgId ?? undefined
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ // src/utils.ts
166
+ function generateDefaultTitle(args) {
167
+ if (args.title)
168
+ return args.title;
169
+ const summary = args.prompt.length > 60 ? args.prompt.slice(0, 57) + "..." : args.prompt;
170
+ return `(Roundtable) - ${summary}`;
171
+ }
172
+ function getSessionIdFromEvent(event) {
173
+ if (!("properties" in event))
174
+ return;
175
+ const props = event.properties;
176
+ if (typeof props.sessionID === "string")
177
+ return props.sessionID;
178
+ if (props.info && typeof props.info === "object") {
179
+ const info = props.info;
180
+ if (info.id)
181
+ return info.id;
182
+ }
183
+ return;
184
+ }
185
+ async function validateAgents(ctx, agentNames) {
186
+ const errors = [];
187
+ if (agentNames.length < 2) {
188
+ errors.push("At least 2 agents are required");
189
+ return { valid: false, available: [], errors };
190
+ }
191
+ let available = [];
192
+ try {
193
+ const result = await ctx.client.app.agents();
194
+ available = result.data.map((a) => ({
195
+ name: a.name,
196
+ description: a.description
197
+ }));
198
+ } catch {
199
+ errors.push("Failed to fetch available agents from server");
200
+ return { valid: false, available: [], errors };
201
+ }
202
+ const availableNames = new Set(available.map((a) => a.name));
203
+ for (const name of agentNames) {
204
+ if (!availableNames.has(name))
205
+ errors.push(`Agent "${name}" not found`);
206
+ }
207
+ return { valid: errors.length === 0, available, errors };
208
+ }
209
+ function arraysEqual(a, b) {
210
+ if (a.length !== b.length)
211
+ return false;
212
+ for (let i = 0;i < a.length; i++)
213
+ if (a[i] !== b[i])
214
+ return false;
215
+ return true;
216
+ }
217
+ function detectLoop(history) {
218
+ if (history.length < 2)
219
+ return false;
220
+ const last = history[history.length - 1].response;
221
+ const prev = history[history.length - 2].response;
222
+ const bigrams = (s) => {
223
+ const set = new Set;
224
+ const cleaned = s.replace(/[\s\n\r]+/g, " ");
225
+ for (let i = 0;i < cleaned.length - 1; i++)
226
+ set.add(cleaned.slice(i, i + 2));
227
+ return set;
228
+ };
229
+ const lastBigrams = bigrams(last);
230
+ const prevBigrams = bigrams(prev);
231
+ let intersection = 0;
232
+ for (const b of lastBigrams)
233
+ if (prevBigrams.has(b))
234
+ intersection++;
235
+ const union = new Set([...lastBigrams, ...prevBigrams]);
236
+ if (union.size === 0)
237
+ return false;
238
+ return intersection / union.size > getConfig().loopSimilarityThreshold;
239
+ }
240
+ function extractResponse(parts) {
241
+ for (const part of parts)
242
+ if (part.type === "text")
243
+ return part.text;
244
+ return null;
245
+ }
246
+ function buildToolSummary(part) {
247
+ if (part.type !== "tool")
248
+ return null;
249
+ const toolName = part.tool;
250
+ const state = part.state;
251
+ let outputPreview;
252
+ switch (state.status) {
253
+ case "completed":
254
+ outputPreview = state.output.slice(0, getConfig().toolOutputPreviewMax);
255
+ break;
256
+ case "error":
257
+ outputPreview = (state.output ?? "(unknown error)").slice(0, getConfig().toolOutputPreviewMax);
258
+ break;
259
+ case "running":
260
+ case "pending":
261
+ outputPreview = `(${state.status})`;
262
+ break;
263
+ default:
264
+ outputPreview = "(unknown)";
265
+ }
266
+ return { toolName, outputPreview };
267
+ }
268
+ function buildToolSummaries(parts) {
269
+ const summaries = [];
270
+ for (const part of parts) {
271
+ const summary = buildToolSummary(part);
272
+ if (summary)
273
+ summaries.push(summary);
274
+ }
275
+ return summaries;
276
+ }
277
+ function buildConsolidatedSummary(state) {
278
+ const lines = [];
279
+ lines.push("━━━ Roundtable Concluded ━━━");
280
+ lines.push(`Topic: ${state.prompt}`);
281
+ lines.push(`Participants: ${state.agents.join(", ")}`);
282
+ if (state.errors.length > 0)
283
+ lines.push(`Errors: ${state.errors.join("; ")}`);
284
+ lines.push("");
285
+ for (const entry of state.history) {
286
+ const label = entry.agent === "observer" ? "Observer" : `${entry.agent} (Round ${entry.round + 1})`;
287
+ lines.push(`── ${label} ──`);
288
+ lines.push(entry.response);
289
+ if (entry.toolCalls.length > 0) {
290
+ lines.push(...entry.toolCalls.map((tc) => ` • ${tc.toolName} → ${tc.outputPreview.slice(0, 80)}`));
291
+ }
292
+ if (entry.hasError)
293
+ lines.push(" ⚠ This response had errors");
294
+ lines.push("");
295
+ }
296
+ return lines.join(`
297
+ `);
298
+ }
299
+ async function injectRoundtableDelimiter(ctx, sessionID) {
300
+ const delimiter = [
301
+ "━━━ Roundtable Concluded ━━━",
302
+ "Messages below this line are not part of the original debate.",
303
+ "The result was consolidated in the main session."
304
+ ].join(`
305
+ `);
306
+ await ctx.client.session.prompt({
307
+ path: { id: sessionID },
308
+ body: { noReply: true, parts: [{ type: "text", text: delimiter }] }
309
+ });
310
+ }
311
+ function buildExtendedPrompt(originalPrompt, extendPrompt) {
312
+ const continuationTriggers = [
313
+ "debate more",
314
+ "continue",
315
+ "dive deeper",
316
+ "go deeper",
317
+ "expand on",
318
+ "elaborate",
319
+ "further discuss",
320
+ "keep debating"
321
+ ];
322
+ const lower = extendPrompt.toLowerCase().trim();
323
+ const isContinuation = continuationTriggers.some((trigger) => lower.startsWith(trigger));
324
+ if (isContinuation) {
325
+ return `Original topic: ${originalPrompt}
326
+
327
+ Continuation: ${extendPrompt}`;
328
+ }
329
+ return [
330
+ "Previous discussion history preserved. Original topic was:",
331
+ ` ${originalPrompt}`,
332
+ "",
333
+ `New challenge: ${extendPrompt}`
334
+ ].join(`
335
+ `);
336
+ }
337
+ async function scanOrphanRoundtables(ctx) {
338
+ await ctx.client.app.log({
339
+ body: {
340
+ service: "roundtable",
341
+ level: "debug",
342
+ message: "scanOrphanRoundtables initialized (Phase 1 — no scanning yet; " + "full implementation in Phase 4)",
343
+ extra: { phase: 1 }
344
+ }
345
+ });
346
+ }
347
+
348
+ // src/prompts.ts
349
+ function buildAgentPrompt(state, agent) {
350
+ const lines = [];
351
+ const agentList = state.agents.join(" → ");
352
+ const roundInfo = `Round ${state.currentRound + 1}/${state.totalRounds} | Current agent: ${agent}`;
353
+ if (state.currentRound === 0 && state.currentAgentIndex === 0) {
354
+ lines.push(`[System] You are participating in a roundtable debate. Agents (${agentList}) will discuss the topic below in ${state.totalRounds} round(s).`);
355
+ lines.push(`[System] The [ROUNDTABLE META] block above is internal routing data — ignore it for all analysis. Focus only on the topic given in [Topic] below.`);
356
+ lines.push(`[Topic] ${state.prompt}`);
357
+ lines.push("");
358
+ }
359
+ lines.push(roundInfo);
360
+ const isLastAgent = state.currentAgentIndex === state.agents.length - 1;
361
+ const isLastRound = state.currentRound === state.totalRounds - 1;
362
+ if (isLastAgent && isLastRound) {
363
+ lines.push("[System] This is the last prompt — finalize your thoughts");
364
+ }
365
+ return lines.join(`
366
+ `);
367
+ }
368
+ function buildObserverPrompt(state, observer) {
369
+ const observerPrompt = getConfig().defaultObserverPrompt;
370
+ if (observer === "built-in")
371
+ return observerPrompt;
372
+ return `You are an impartial roundtable observer.
373
+ ` + `Your role: ${observer}. Provide an executive summary of the debate.
374
+
375
+ ` + observerPrompt;
376
+ }
377
+
378
+ // src/handlers.ts
379
+ async function startNewRoundtable(ctx, args, toolCtx) {
380
+ const agents = args.agents;
381
+ const newSession = await ctx.client.session.create({
382
+ body: { title: generateDefaultTitle({ ...args, agents }) }
383
+ });
384
+ const sessionID = newSession.data.id;
385
+ const parentSessionID = toolCtx.sessionID;
386
+ const state = {
387
+ sessionID,
388
+ parentSessionID,
389
+ agents,
390
+ totalRounds: args.rounds,
391
+ observer: args.observer ?? "built-in",
392
+ prompt: args.prompt,
393
+ currentRound: 0,
394
+ currentAgentIndex: 0,
395
+ phase: "active",
396
+ history: [],
397
+ errors: [],
398
+ createdAt: Date.now(),
399
+ currentGeneration: 0,
400
+ userInterjections: []
401
+ };
402
+ states.set(sessionID, state);
403
+ try {
404
+ await ctx.client.session.prompt({
405
+ path: { id: sessionID },
406
+ body: { noReply: true, parts: [{ type: "text", text: serializeState(state) }] }
407
+ });
408
+ await sendToAgent(ctx, state);
409
+ await ctx.client.session.prompt({
410
+ path: { id: parentSessionID },
411
+ body: {
412
+ noReply: true,
413
+ parts: [{
414
+ type: "text",
415
+ text: `The roundtable has started (agents: ${agents.join(", ")}, ${args.rounds} round(s)). To extend it later, use the tool roundtable({sessionID: "${sessionID}", rounds: N, prompt: "..."})`
416
+ }]
417
+ }
418
+ });
419
+ await ctx.client.tui.showToast({
420
+ body: {
421
+ message: `Roundtable started in #${sessionID} (${agents.join(" → ")} · ${args.rounds} round(s))`,
422
+ variant: "info"
423
+ }
424
+ });
425
+ return sessionID;
426
+ } catch (err) {
427
+ states.delete(sessionID);
428
+ throw err;
429
+ }
430
+ }
431
+ async function sendToAgent(ctx, state) {
432
+ try {
433
+ const agent = state.agents[state.currentAgentIndex];
434
+ const prompt = buildAgentPrompt(state, agent);
435
+ await ctx.client.session.prompt({
436
+ path: { id: state.sessionID },
437
+ body: { agent, parts: [{ type: "text", text: prompt }] }
438
+ });
439
+ state.currentGeneration++;
440
+ const capturedGeneration = state.currentGeneration;
441
+ const handle = setTimeout(async () => {
442
+ timeoutHandles.delete(state.sessionID);
443
+ if (state.currentGeneration !== capturedGeneration)
444
+ return;
445
+ try {
446
+ await ctx.client.session.abort({ path: { id: state.sessionID } });
447
+ state.errors.push(`Agent "${agent}" timed out after ${getConfig().defaultTimeoutMs / 1000}s`);
448
+ } catch {}
449
+ }, getConfig().defaultTimeoutMs);
450
+ timeoutHandles.set(state.sessionID, handle);
451
+ await updateSessionTitle(ctx, state);
452
+ await ctx.client.app.log({
453
+ body: {
454
+ service: "roundtable",
455
+ level: "debug",
456
+ message: `Turn started: ${agent} (R${state.currentRound + 1}/${state.totalRounds})`,
457
+ extra: {
458
+ sessionID: state.sessionID,
459
+ agent,
460
+ round: state.currentRound + 1,
461
+ totalRounds: state.totalRounds,
462
+ generation: state.currentGeneration
463
+ }
464
+ }
465
+ });
466
+ } catch (err) {
467
+ state.errors.push(`Failed to send to agent "${state.agents[state.currentAgentIndex]}": ${err instanceof Error ? err.message : String(err)}`);
468
+ const handle = timeoutHandles.get(state.sessionID);
469
+ if (handle) {
470
+ clearTimeout(handle);
471
+ timeoutHandles.delete(state.sessionID);
472
+ }
473
+ }
474
+ }
475
+ async function updateSessionTitle(ctx, state) {
476
+ const summary = state.prompt.length > 60 ? state.prompt.slice(0, 57) + "..." : state.prompt;
477
+ await ctx.client.session.update({
478
+ path: { id: state.sessionID },
479
+ body: { title: `(Roundtable) - ${summary}` }
480
+ });
481
+ }
482
+ async function sendObserverPrompt(ctx, state) {
483
+ try {
484
+ const prompt = buildObserverPrompt(state, state.observer);
485
+ if (state.observer === "built-in") {
486
+ await ctx.client.session.prompt({
487
+ path: { id: state.sessionID },
488
+ body: { noReply: false, parts: [{ type: "text", text: prompt }] }
489
+ });
490
+ } else {
491
+ await ctx.client.session.prompt({
492
+ path: { id: state.sessionID },
493
+ body: { agent: state.observer, parts: [{ type: "text", text: prompt }] }
494
+ });
495
+ }
496
+ await ctx.client.app.log({
497
+ body: {
498
+ service: "roundtable",
499
+ level: "debug",
500
+ message: `Observer prompt sent: ${state.observer}`,
501
+ extra: { sessionID: state.sessionID, observer: state.observer }
502
+ }
503
+ });
504
+ } catch (err) {
505
+ state.errors.push(`Failed to send observer prompt: ${err instanceof Error ? err.message : String(err)}`);
506
+ state.phase = "aborted";
507
+ await finalizeRoundtable(ctx, state);
508
+ }
509
+ }
510
+ async function finalizeRoundtable(ctx, state) {
511
+ const sessionID = state.sessionID;
512
+ const timeoutHandle = timeoutHandles.get(sessionID);
513
+ if (timeoutHandle) {
514
+ clearTimeout(timeoutHandle);
515
+ timeoutHandles.delete(sessionID);
516
+ }
517
+ try {
518
+ const summary = buildConsolidatedSummary(state);
519
+ const pending = pendingResults.get(sessionID);
520
+ if (pending) {
521
+ pending.resolve(summary);
522
+ pendingResults.delete(sessionID);
523
+ }
524
+ await injectRoundtableDelimiter(ctx, sessionID);
525
+ const shortPrompt = state.prompt.length > 50 ? state.prompt.slice(0, 47) + "..." : state.prompt;
526
+ await ctx.client.session.update({
527
+ path: { id: sessionID },
528
+ body: { title: `(Roundtable) - ${shortPrompt} · CONCLUDED` }
529
+ });
530
+ try {
531
+ await ctx.client.session.prompt({
532
+ path: { id: sessionID },
533
+ body: { noReply: true, parts: [{ type: "text", text: serializeState(state) }] }
534
+ });
535
+ } catch {}
536
+ await ctx.client.tui.showToast({
537
+ body: { message: "Roundtable concluded", variant: "success" }
538
+ });
539
+ } catch (err) {
540
+ try {
541
+ await ctx.client.app.log({
542
+ body: {
543
+ service: "roundtable",
544
+ level: "error",
545
+ message: `Failed to finalize roundtable #${sessionID}: ${err instanceof Error ? err.message : String(err)}`,
546
+ extra: { sessionID, phase: state.phase }
547
+ }
548
+ });
549
+ } catch {}
550
+ } finally {
551
+ states.delete(sessionID);
552
+ try {
553
+ await ctx.client.app.log({
554
+ body: {
555
+ service: "roundtable",
556
+ level: "debug",
557
+ message: `State cleaned up for session #${sessionID}`,
558
+ extra: { sessionID, phase: state.phase }
559
+ }
560
+ });
561
+ } catch {}
562
+ }
563
+ }
564
+ async function processNextTurn(ctx, state) {
565
+ if (state.phase === "done" || state.phase === "aborted")
566
+ return;
567
+ const result = await ctx.client.session.messages({ path: { id: state.sessionID } });
568
+ const messages = result.data;
569
+ const userMsgs = messages.filter((m) => m.info.role === "user");
570
+ for (const msg of userMsgs) {
571
+ for (const part of msg.parts) {
572
+ if (part.type === "text" && !part.text.includes("[ROUNDTABLE META")) {
573
+ if (!state.userInterjections.includes(part.text)) {
574
+ state.userInterjections.push(part.text);
575
+ }
576
+ }
577
+ }
578
+ }
579
+ const assistantMsgs = messages.filter((m) => m.info.role === "assistant");
580
+ if (assistantMsgs.length === 0)
581
+ return;
582
+ const latestMsg = assistantMsgs[assistantMsgs.length - 1];
583
+ if (state.lastProcessedMsgId === latestMsg.info.id)
584
+ return;
585
+ state.lastProcessedMsgId = latestMsg.info.id;
586
+ if (state.phase === "active") {
587
+ const response = extractResponse(latestMsg.parts);
588
+ if (!response) {
589
+ state.errors.push(`Agent ${state.agents[state.currentAgentIndex]} returned no text in round ${state.currentRound + 1}`);
590
+ }
591
+ const toolCalls = buildToolSummaries(latestMsg.parts);
592
+ const entry = {
593
+ agent: state.agents[state.currentAgentIndex],
594
+ round: state.currentRound,
595
+ response: response ?? "(no text response)",
596
+ toolCalls,
597
+ hasError: response === null
598
+ };
599
+ state.history.push(entry);
600
+ await ctx.client.app.log({
601
+ body: {
602
+ service: "roundtable",
603
+ level: "debug",
604
+ message: `Turn complete: ${entry.agent} (R${state.currentRound + 1})`,
605
+ extra: {
606
+ sessionID: state.sessionID,
607
+ agent: entry.agent,
608
+ round: state.currentRound + 1,
609
+ responseLength: entry.response.length,
610
+ toolCallCount: entry.toolCalls.length,
611
+ hasError: entry.hasError
612
+ }
613
+ }
614
+ });
615
+ if (detectLoop(state.history)) {
616
+ state.errors.push("Loop detected — agents reached an impasse");
617
+ state.phase = "done";
618
+ await ctx.client.app.log({
619
+ body: {
620
+ service: "roundtable",
621
+ level: "debug",
622
+ message: "Loop detected — finalizing early",
623
+ extra: { sessionID: state.sessionID, similarity: getConfig().loopSimilarityThreshold }
624
+ }
625
+ });
626
+ await finalizeRoundtable(ctx, state);
627
+ return;
628
+ }
629
+ const nextIndex = state.currentAgentIndex + 1;
630
+ if (nextIndex < state.agents.length) {
631
+ state.currentAgentIndex = nextIndex;
632
+ await sendToAgent(ctx, state);
633
+ } else if (state.currentRound + 1 < state.totalRounds) {
634
+ state.currentRound++;
635
+ state.currentAgentIndex = 0;
636
+ await sendToAgent(ctx, state);
637
+ } else {
638
+ state.phase = "observing";
639
+ await ctx.client.app.log({
640
+ body: {
641
+ service: "roundtable",
642
+ level: "debug",
643
+ message: "All rounds complete — transitioning to observer",
644
+ extra: { sessionID: state.sessionID, totalRounds: state.totalRounds }
645
+ }
646
+ });
647
+ await sendObserverPrompt(ctx, state);
648
+ }
649
+ return;
650
+ }
651
+ if (state.phase === "observing") {
652
+ const summary = extractResponse(latestMsg.parts);
653
+ state.history.push({
654
+ agent: "observer",
655
+ round: state.currentRound,
656
+ response: summary ?? "(no summary)",
657
+ toolCalls: [],
658
+ hasError: summary === null
659
+ });
660
+ state.phase = "done";
661
+ await finalizeRoundtable(ctx, state);
662
+ }
663
+ }
664
+ async function handleAgentError(ctx, state, event) {
665
+ if (state.phase === "observing" || state.phase === "done") {
666
+ state.phase = "aborted";
667
+ await finalizeRoundtable(ctx, state);
668
+ return;
669
+ }
670
+ const agent = state.agents[state.currentAgentIndex];
671
+ const errorMsg = event.type === "session.error" && event.properties.error ? typeof event.properties.error === "object" ? event.properties.error.message ?? JSON.stringify(event.properties.error) : String(event.properties.error) : "Unknown error";
672
+ state.errors.push(`Agent "${agent}" failed on round ${state.currentRound + 1}: ${errorMsg}`);
673
+ const handle = timeoutHandles.get(state.sessionID);
674
+ if (handle) {
675
+ clearTimeout(handle);
676
+ timeoutHandles.delete(state.sessionID);
677
+ }
678
+ await ctx.client.tui.showToast({
679
+ body: {
680
+ message: `"${agent}" failed on Round ${state.currentRound + 1}. Skipping to next.`,
681
+ variant: "warning"
682
+ }
683
+ });
684
+ const nextIndex = state.currentAgentIndex + 1;
685
+ if (nextIndex < state.agents.length) {
686
+ state.currentAgentIndex = nextIndex;
687
+ try {
688
+ await ctx.client.app.log({
689
+ body: {
690
+ service: "roundtable",
691
+ level: "debug",
692
+ message: "Agent skipped due to error",
693
+ extra: { sessionID: state.sessionID, failedAgent: agent, nextAgent: state.agents[state.currentAgentIndex], round: state.currentRound + 1 }
694
+ }
695
+ });
696
+ } catch {}
697
+ await sendToAgent(ctx, state);
698
+ } else if (state.currentRound + 1 < state.totalRounds) {
699
+ state.currentRound++;
700
+ state.currentAgentIndex = 0;
701
+ try {
702
+ await ctx.client.app.log({
703
+ body: {
704
+ service: "roundtable",
705
+ level: "debug",
706
+ message: "Agent skipped due to error — moving to next round",
707
+ extra: { sessionID: state.sessionID, failedAgent: agent, round: state.currentRound + 1 }
708
+ }
709
+ });
710
+ } catch {}
711
+ await sendToAgent(ctx, state);
712
+ } else {
713
+ state.phase = "aborted";
714
+ try {
715
+ await ctx.client.tui.showToast({
716
+ body: { message: "All agents failed — roundtable aborted", variant: "error" }
717
+ });
718
+ } catch {}
719
+ await finalizeRoundtable(ctx, state);
720
+ }
721
+ }
722
+ async function handleSessionDeleted(ctx, state, deletedSessionID) {
723
+ const handle = timeoutHandles.get(state.sessionID);
724
+ if (handle) {
725
+ clearTimeout(handle);
726
+ timeoutHandles.delete(state.sessionID);
727
+ }
728
+ if (deletedSessionID === state.sessionID) {
729
+ state.phase = "aborted";
730
+ const partialSummary = buildConsolidatedSummary(state);
731
+ const output = [
732
+ "[Roundtable interrupted — session closed]",
733
+ "Partial history up to interruption:",
734
+ "",
735
+ partialSummary
736
+ ].join(`
737
+ `);
738
+ const pending = pendingResults.get(state.sessionID);
739
+ if (pending) {
740
+ pending.resolve(output);
741
+ pendingResults.delete(state.sessionID);
742
+ }
743
+ try {
744
+ await ctx.client.tui.showToast({
745
+ body: { message: "Roundtable interrupted", variant: "warning" }
746
+ });
747
+ } catch {}
748
+ try {
749
+ await ctx.client.app.log({
750
+ body: {
751
+ service: "roundtable",
752
+ level: "debug",
753
+ message: "S2 deleted — pending promise resolved with partial result",
754
+ extra: { sessionID: deletedSessionID, parentSessionID: state.parentSessionID }
755
+ }
756
+ });
757
+ } catch {}
758
+ } else if (deletedSessionID === state.parentSessionID) {
759
+ state.phase = "aborted";
760
+ try {
761
+ await ctx.client.session.abort({ path: { id: state.sessionID } });
762
+ } catch {}
763
+ try {
764
+ await ctx.client.app.log({
765
+ body: {
766
+ service: "roundtable",
767
+ level: "debug",
768
+ message: "S1 deleted — S2 aborted",
769
+ extra: { sessionID: state.sessionID, parentSessionID: deletedSessionID }
770
+ }
771
+ });
772
+ } catch {}
773
+ }
774
+ states.delete(state.sessionID);
775
+ }
776
+ async function extendRoundtable(ctx, args, _toolCtx) {
777
+ const sessionID = args.sessionID;
778
+ try {
779
+ await ctx.client.session.get({ path: { id: sessionID } });
780
+ } catch {
781
+ return `Error: Session #${sessionID} not found or inaccessible. Cannot extend.`;
782
+ }
783
+ let messagesData;
784
+ try {
785
+ messagesData = await ctx.client.session.messages({ path: { id: sessionID } });
786
+ } catch {
787
+ return `Error: Could not read messages from session #${sessionID}`;
788
+ }
789
+ const serializedRaw = findSerializedState(messagesData.data);
790
+ if (!serializedRaw) {
791
+ return [
792
+ `Error: No roundtable state found in session #${sessionID}.`,
793
+ "This session is not a roundtable or the state was lost during compaction."
794
+ ].join(`
795
+ `);
796
+ }
797
+ const originalState = deserializeState(serializedRaw);
798
+ if (!originalState) {
799
+ return `Error: Corrupt roundtable state in session #${sessionID}. Cannot extend.`;
800
+ }
801
+ if (originalState.phase === "done") {} else if (originalState.phase === "active") {
802
+ const assistantCount = messagesData.data.filter((m) => m.info.role === "assistant").length;
803
+ const expectedAssistantCount = originalState.totalRounds * originalState.agents.length + 1;
804
+ if (assistantCount >= expectedAssistantCount) {
805
+ originalState.phase = "done";
806
+ try {
807
+ await ctx.client.app.log({
808
+ body: {
809
+ service: "roundtable",
810
+ level: "info",
811
+ message: `Recovered stuck session #${sessionID}: phase was active but ${assistantCount} assistant msgs suggest debate completed.`,
812
+ extra: { sessionID, assistantCount, expectedAssistantCount }
813
+ }
814
+ });
815
+ } catch {}
816
+ } else {
817
+ return [
818
+ `Error: Roundtable #${sessionID} is still active (phase: ${originalState.phase}).`,
819
+ "Cannot extend until the roundtable concludes."
820
+ ].join(`
821
+ `);
822
+ }
823
+ } else {
824
+ return [
825
+ `Error: Roundtable #${sessionID} is in state "${originalState.phase}" and cannot be extended.`
826
+ ].join(`
827
+ `);
828
+ }
829
+ if (args.agents && !arraysEqual(args.agents, originalState.agents)) {
830
+ return [
831
+ "Error: Agent mismatch.",
832
+ `Original: ${originalState.agents.join(", ")}`,
833
+ `Provided: ${args.agents.join(", ")}`,
834
+ "Extend must use the same agents as the original roundtable."
835
+ ].join(`
836
+ `);
837
+ }
838
+ try {
839
+ const storedValidation = await validateAgents(ctx, originalState.agents);
840
+ if (!storedValidation.valid) {
841
+ return [
842
+ "Error: One or more agents from the original roundtable no longer exist.",
843
+ ...storedValidation.errors.map((e) => ` - ${e}`)
844
+ ].join(`
845
+ `);
846
+ }
847
+ } catch {
848
+ return "Error: Failed to validate agents. Cannot extend.";
849
+ }
850
+ const extendedPrompt = buildExtendedPrompt(originalState.prompt, args.prompt);
851
+ const newState = {
852
+ sessionID: originalState.sessionID,
853
+ parentSessionID: originalState.parentSessionID,
854
+ agents: originalState.agents,
855
+ totalRounds: originalState.totalRounds + args.rounds,
856
+ observer: originalState.observer,
857
+ prompt: extendedPrompt,
858
+ currentRound: originalState.currentRound,
859
+ currentAgentIndex: 0,
860
+ phase: "active",
861
+ history: [...originalState.history],
862
+ errors: [...originalState.errors],
863
+ createdAt: Date.now(),
864
+ currentGeneration: 0,
865
+ userInterjections: [...originalState.userInterjections ?? []]
866
+ };
867
+ states.set(sessionID, newState);
868
+ try {
869
+ await ctx.client.session.prompt({
870
+ path: { id: sessionID },
871
+ body: { noReply: true, parts: [{ type: "text", text: serializeState(newState) }] }
872
+ });
873
+ const agentList = originalState.agents.join(" vs ");
874
+ await ctx.client.session.update({
875
+ path: { id: sessionID },
876
+ body: { title: `Roundtable: ${agentList} (R${newState.currentRound + 1}/${newState.totalRounds})` }
877
+ });
878
+ await ctx.client.session.prompt({
879
+ path: { id: originalState.parentSessionID },
880
+ body: {
881
+ noReply: true,
882
+ parts: [{
883
+ type: "text",
884
+ text: `The roundtable has been extended (${args.rounds} more round(s)). To extend again, use the tool roundtable({sessionID: "${sessionID}", rounds: N, prompt: "..."})`
885
+ }]
886
+ }
887
+ });
888
+ await sendToAgent(ctx, newState);
889
+ await ctx.client.tui.showToast({
890
+ body: {
891
+ message: `Roundtable #${sessionID} extended — ${args.rounds} more round(s) (${originalState.agents.join(" → ")})`,
892
+ variant: "info"
893
+ }
894
+ });
895
+ return sessionID;
896
+ } catch (err) {
897
+ states.delete(sessionID);
898
+ throw err;
899
+ }
900
+ }
901
+
902
+ // index.ts
903
+ var RoundtablePlugin = async (ctx) => {
904
+ try {
905
+ await loadConfig(ctx);
906
+ } catch {}
907
+ try {
908
+ await scanOrphanRoundtables(ctx);
909
+ } catch (err) {
910
+ try {
911
+ await ctx.client.app.log({
912
+ body: {
913
+ service: "roundtable",
914
+ level: "error",
915
+ message: `scanOrphanRoundtables failed: ${err instanceof Error ? err.message : String(err)}`
916
+ }
917
+ });
918
+ } catch {}
919
+ }
920
+ return {
921
+ event: async ({ event }) => {
922
+ const sessionID = getSessionIdFromEvent(event);
923
+ if (!sessionID || !states.has(sessionID))
924
+ return;
925
+ const state = states.get(sessionID);
926
+ switch (event.type) {
927
+ case "session.idle": {
928
+ const handle = timeoutHandles.get(sessionID);
929
+ if (handle) {
930
+ clearTimeout(handle);
931
+ timeoutHandles.delete(sessionID);
932
+ }
933
+ await processNextTurn(ctx, state);
934
+ break;
935
+ }
936
+ case "session.error": {
937
+ await handleAgentError(ctx, state, event);
938
+ break;
939
+ }
940
+ case "session.deleted": {
941
+ const deletedID = event.properties.info?.id;
942
+ if (deletedID)
943
+ await handleSessionDeleted(ctx, state, deletedID);
944
+ break;
945
+ }
946
+ }
947
+ },
948
+ "experimental.session.compacting": async (_input, _output) => {},
949
+ tool: {
950
+ roundtable: tool({
951
+ description: "Starts a multi-agent roundtable debate where agents discuss a topic turn by turn. " + `After all rounds, a built-in observer consolidates the discussion into an executive summary.
952
+
953
+ ` + `Returns an object with { sessionID, summary } after the debate concludes.
954
+
955
+ ` + "Note: this tool injects system-level prompts into each agent's context during " + `the debate (role-setting, topic, turn routing, and lifecycle signals).
956
+
957
+ ` + "Choosing agents: select agents based on their expertise (e.g., pm for product decisions, " + "dev for technical trade-offs, rv for code review). You can also specify in the prompt " + `what each agent should focus on (e.g., 'pm: focus on cost; dev: focus on maintainability').
958
+
959
+ ` + "Multiple rounds: for complex topics, use 2+ rounds and INCLUDE per-round focus " + "instructions IN the prompt itself (e.g., prompt: 'Round 1: list pros. Round 2: " + "list cons. Round 3: propose an implementation plan'). This way all agents see " + "the full agenda. The default is 1 round.",
960
+ args: {
961
+ agents: tool.schema.array(tool.schema.string()).min(2).describe("Agent names in speaking order (minimum 2). Choose agents based on " + 'their expertise — each brings a different perspective. Example: ["pm", "dev", "rv"]'),
962
+ prompt: tool.schema.string().describe("Topic or challenge for the agents to debate. For multi-round debates, " + "include per-round instructions here (e.g., 'Round 1: pros. Round 2: cons. " + "Round 3: plan.'). All agents will see this and follow the round structure."),
963
+ rounds: tool.schema.number().min(1).max(50).describe("Number of complete rounds (each round = all agents speak once). " + "Default: 1. Max: 50. For complex topics with 2+ rounds, include per-round focus " + "instructions in the prompt parameter so all agents see the agenda."),
964
+ observer: tool.schema.string().optional().describe("Agent name for final consolidation. The observer does not debate — it " + "summarizes after all rounds. Omit to use the built-in observer."),
965
+ sessionID: tool.schema.string().optional().describe("Session ID (format: ses_xxxx) from a previous roundtable call to " + "continue a concluded debate. Omit this parameter and pass agents + " + "prompt to start a fresh debate."),
966
+ title: tool.schema.string().optional().describe("Custom title for the session (max 200 chars). If omitted, auto-generated " + 'as "(Roundtable) - {first 80 chars of prompt, truncated at word boundary}".')
967
+ },
968
+ async execute(args, toolCtx) {
969
+ try {
970
+ const rounds = args.rounds ?? 1;
971
+ if (args.sessionID) {
972
+ if (typeof args.sessionID !== "string" || !args.sessionID.trim()) {
973
+ return "Error: sessionID is empty";
974
+ }
975
+ if (args.agents) {
976
+ return "Error: pass either sessionID (extend) or agents (new), not both";
977
+ }
978
+ const sid2 = await extendRoundtable(ctx, args, toolCtx);
979
+ if (sid2.startsWith("Error:") || sid2.startsWith("Invalid"))
980
+ return sid2;
981
+ const result2 = await new Promise((resolve) => pendingResults.set(sid2, { resolve }));
982
+ return result2;
983
+ }
984
+ if (!args.agents || !Array.isArray(args.agents) || args.agents.length < 2) {
985
+ return "Error: 'agents' with at least 2 names is required";
986
+ }
987
+ const validation = await validateAgents(ctx, args.agents);
988
+ if (!validation.valid) {
989
+ const available = validation.available.map((a) => a.name).join(", ");
990
+ return ["Invalid agent configuration:", ...validation.errors.map((e) => ` - ${e}`), `Available agents: ${available}`].join(`
991
+ `);
992
+ }
993
+ if (rounds > 50) {
994
+ return "Error: Maximum 50 rounds allowed";
995
+ }
996
+ const sid = await startNewRoundtable(ctx, { ...args, rounds }, toolCtx);
997
+ const result = await new Promise((resolve) => pendingResults.set(sid, { resolve }));
998
+ return result;
999
+ } catch (err) {
1000
+ await ctx.client.app.log({
1001
+ body: {
1002
+ service: "roundtable",
1003
+ level: "error",
1004
+ message: `roundtable.execute error: ${err instanceof Error ? err.stack || err.message : String(err)}`
1005
+ }
1006
+ });
1007
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1008
+ }
1009
+ }
1010
+ }),
1011
+ available_agents: tool({
1012
+ description: "Lists all configured agents and their roles. " + 'Returns a formatted list of agent names (e.g., "Available agents: pm, dev, rv"). ' + "Use this to choose which agents should participate in a roundtable " + "based on their descriptions — then call roundtable() with your selection.",
1013
+ args: {},
1014
+ async execute() {
1015
+ try {
1016
+ const result = await ctx.client.app.agents();
1017
+ const names = result.data.map((a) => a.name);
1018
+ return `Available agents: ${names.join(", ")}`;
1019
+ } catch {
1020
+ return "Error: Could not fetch agent list. The server might not be ready.";
1021
+ }
1022
+ }
1023
+ })
1024
+ }
1025
+ };
1026
+ };
1027
+ export {
1028
+ RoundtablePlugin
1029
+ };
1030
+
1031
+ //# debugId=3293C14B588953DF64756E2164756E21
1032
+ //# sourceMappingURL=index.js.map