@rrr2010/opencode-roundtable 0.2.0 → 0.3.2

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 CHANGED
@@ -28,7 +28,8 @@ var DEFAULT_CONFIG = {
28
28
  "5. **Suggested next steps**"
29
29
  ].join(`
30
30
  `),
31
- maxRounds: 10
31
+ maxRounds: 10,
32
+ navigation: "link"
32
33
  };
33
34
  var config = { ...DEFAULT_CONFIG };
34
35
  function getConfig() {
@@ -40,7 +41,8 @@ function validateConfig(raw) {
40
41
  loopSimilarityThreshold: typeof raw.loopSimilarityThreshold === "number" && raw.loopSimilarityThreshold >= 0 && raw.loopSimilarityThreshold <= 1 ? raw.loopSimilarityThreshold : DEFAULT_CONFIG.loopSimilarityThreshold,
41
42
  toolOutputPreviewMax: typeof raw.toolOutputPreviewMax === "number" && raw.toolOutputPreviewMax >= 100 ? raw.toolOutputPreviewMax : DEFAULT_CONFIG.toolOutputPreviewMax,
42
43
  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
+ maxRounds: typeof raw.maxRounds === "number" && raw.maxRounds >= 1 ? raw.maxRounds : DEFAULT_CONFIG.maxRounds,
45
+ navigation: raw.navigation === "selectSession" || raw.navigation === "none" || raw.navigation === "auto" ? raw.navigation : DEFAULT_CONFIG.navigation
44
46
  };
45
47
  }
46
48
  async function loadConfig(ctx) {
@@ -91,43 +93,31 @@ async function loadConfig(ctx) {
91
93
  }
92
94
 
93
95
  // src/state.ts
96
+ import { readFile as readFile2, writeFile as writeFile2, unlink, readdir, mkdir } from "fs/promises";
97
+ import { join as join2 } from "path";
98
+ import os2 from "os";
99
+ var statesDir = (() => {
100
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
101
+ const base = xdgConfigHome ? join2(xdgConfigHome, "opencode") : join2(os2.homedir(), ".config", "opencode");
102
+ return join2(base, "roundtable-states");
103
+ })();
94
104
  var states = new Map;
95
105
  var timeoutHandles = new Map;
96
106
  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]`;
107
+ function stateFilePath(sessionID) {
108
+ return join2(statesDir, `${sessionID}.json`);
100
109
  }
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;
110
+ async function saveStateFile(state) {
111
+ try {
112
+ await mkdir(statesDir, { recursive: true });
113
+ } catch {}
114
+ const filePath = stateFilePath(state.sessionID);
115
+ await writeFile2(filePath, JSON.stringify(state, null, 2), "utf-8");
113
116
  }
114
- function deserializeState(raw) {
117
+ async function loadStateFile(sessionID) {
115
118
  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);
119
+ const content = await readFile2(stateFilePath(sessionID), "utf-8");
120
+ const parsed = JSON.parse(content);
131
121
  if (typeof parsed !== "object" || !parsed)
132
122
  return null;
133
123
  if (typeof parsed.sessionID !== "string")
@@ -161,6 +151,19 @@ function deserializeState(raw) {
161
151
  return null;
162
152
  }
163
153
  }
154
+ async function deleteStateFile(sessionID) {
155
+ try {
156
+ await unlink(stateFilePath(sessionID));
157
+ } catch {}
158
+ }
159
+ async function listStateFiles() {
160
+ try {
161
+ const entries = await readdir(statesDir);
162
+ return entries.filter((e) => e.endsWith(".json")).map((e) => e.replace(/\.json$/, ""));
163
+ } catch {
164
+ return [];
165
+ }
166
+ }
164
167
 
165
168
  // src/utils.ts
166
169
  function generateDefaultTitle(args) {
@@ -334,13 +337,44 @@ Continuation: ${extendPrompt}`;
334
337
  ].join(`
335
338
  `);
336
339
  }
340
+ async function navigateToSession(ctx, targetID, _parentID) {
341
+ try {
342
+ if (typeof ctx.client.tui.selectSession === "function") {
343
+ await ctx.client.tui.selectSession({ sessionID: targetID });
344
+ return true;
345
+ }
346
+ await ctx.client.tui.publish({
347
+ body: {
348
+ type: "tui.session.select",
349
+ properties: { sessionID: targetID }
350
+ }
351
+ });
352
+ return true;
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
337
357
  async function scanOrphanRoundtables(ctx) {
358
+ const sessionIDs = await listStateFiles();
359
+ let loaded = 0;
360
+ let errors = 0;
361
+ for (const sid of sessionIDs) {
362
+ try {
363
+ const state = await loadStateFile(sid);
364
+ if (state) {
365
+ states.set(sid, state);
366
+ loaded++;
367
+ }
368
+ } catch {
369
+ errors++;
370
+ }
371
+ }
338
372
  await ctx.client.app.log({
339
373
  body: {
340
374
  service: "roundtable",
341
375
  level: "debug",
342
- message: "scanOrphanRoundtables initialized (Phase 1 no scanning yet; " + "full implementation in Phase 4)",
343
- extra: { phase: 1 }
376
+ message: `scanOrphanRoundtables: ${loaded} state(s) loaded, ${errors} error(s)`,
377
+ extra: { loaded, errors, total: sessionIDs.length }
344
378
  }
345
379
  });
346
380
  }
@@ -352,7 +386,6 @@ function buildAgentPrompt(state, agent) {
352
386
  const roundInfo = `Round ${state.currentRound + 1}/${state.totalRounds} | Current agent: ${agent}`;
353
387
  if (state.currentRound === 0 && state.currentAgentIndex === 0) {
354
388
  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
389
  lines.push(`[Topic] ${state.prompt}`);
357
390
  lines.push("");
358
391
  }
@@ -366,11 +399,11 @@ function buildAgentPrompt(state, agent) {
366
399
  `);
367
400
  }
368
401
  function buildObserverPrompt(state, observer) {
369
- const observerPrompt = getConfig().defaultObserverPrompt;
402
+ const observerPrompt = state.observerPrompt ?? getConfig().defaultObserverPrompt;
370
403
  if (observer === "built-in")
371
404
  return observerPrompt;
372
405
  return `You are an impartial roundtable observer.
373
- ` + `Your role: ${observer}. Provide an executive summary of the debate.
406
+ ` + `Your role: ${observer}.
374
407
 
375
408
  ` + observerPrompt;
376
409
  }
@@ -379,7 +412,10 @@ function buildObserverPrompt(state, observer) {
379
412
  async function startNewRoundtable(ctx, args, toolCtx) {
380
413
  const agents = args.agents;
381
414
  const newSession = await ctx.client.session.create({
382
- body: { title: generateDefaultTitle({ ...args, agents }) }
415
+ body: {
416
+ title: generateDefaultTitle({ ...args, agents }),
417
+ parentID: toolCtx.sessionID
418
+ }
383
419
  });
384
420
  const sessionID = newSession.data.id;
385
421
  const parentSessionID = toolCtx.sessionID;
@@ -397,14 +433,12 @@ async function startNewRoundtable(ctx, args, toolCtx) {
397
433
  errors: [],
398
434
  createdAt: Date.now(),
399
435
  currentGeneration: 0,
400
- userInterjections: []
436
+ userInterjections: [],
437
+ observerPrompt: args.observerPrompt
401
438
  };
402
439
  states.set(sessionID, state);
403
440
  try {
404
- await ctx.client.session.prompt({
405
- path: { id: sessionID },
406
- body: { noReply: true, parts: [{ type: "text", text: serializeState(state) }] }
407
- });
441
+ await saveStateFile(state);
408
442
  await sendToAgent(ctx, state);
409
443
  await ctx.client.session.prompt({
410
444
  path: { id: parentSessionID },
@@ -412,10 +446,20 @@ async function startNewRoundtable(ctx, args, toolCtx) {
412
446
  noReply: true,
413
447
  parts: [{
414
448
  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: "..."})`
449
+ text: `⚙ Roundtable started #${sessionID} • ${agents.join(" ")} ${args.rounds} round(s)`
416
450
  }]
417
451
  }
418
452
  });
453
+ await ctx.client.session.prompt({
454
+ path: { id: sessionID },
455
+ body: {
456
+ noReply: true,
457
+ parts: [{ type: "text", text: `⚙ Parent: #${parentSessionID}` }]
458
+ }
459
+ });
460
+ if (getConfig().navigation === "auto") {
461
+ await navigateToSession(ctx, sessionID, parentSessionID);
462
+ }
419
463
  await ctx.client.tui.showToast({
420
464
  body: {
421
465
  message: `Roundtable started in #${sessionID} (${agents.join(" → ")} · ${args.rounds} round(s))`,
@@ -473,10 +517,13 @@ async function sendToAgent(ctx, state) {
473
517
  }
474
518
  }
475
519
  async function updateSessionTitle(ctx, state) {
476
- const summary = state.prompt.length > 60 ? state.prompt.slice(0, 57) + "..." : state.prompt;
520
+ const summary = state.prompt.length > 40 ? state.prompt.slice(0, 37) + "..." : state.prompt;
521
+ const agentList = state.agents.join("→");
522
+ const roundInfo = `R${state.currentRound + 1}/${state.totalRounds}`;
523
+ const title = `⚡ "${summary}" · ${agentList} (${roundInfo} · ↑ #${state.parentSessionID})`;
477
524
  await ctx.client.session.update({
478
525
  path: { id: state.sessionID },
479
- body: { title: `(Roundtable) - ${summary}` }
526
+ body: { title }
480
527
  });
481
528
  }
482
529
  async function sendObserverPrompt(ctx, state) {
@@ -522,17 +569,17 @@ async function finalizeRoundtable(ctx, state) {
522
569
  pendingResults.delete(sessionID);
523
570
  }
524
571
  await injectRoundtableDelimiter(ctx, sessionID);
525
- const shortPrompt = state.prompt.length > 50 ? state.prompt.slice(0, 47) + "..." : state.prompt;
572
+ const shortPrompt = state.prompt.length > 40 ? state.prompt.slice(0, 37) + "..." : state.prompt;
526
573
  await ctx.client.session.update({
527
574
  path: { id: sessionID },
528
- body: { title: `(Roundtable) - ${shortPrompt} · CONCLUDED` }
575
+ body: { title: `⚡ "${shortPrompt}" · ${state.agents.join("→")} ✓` }
529
576
  });
530
577
  try {
531
- await ctx.client.session.prompt({
532
- path: { id: sessionID },
533
- body: { noReply: true, parts: [{ type: "text", text: serializeState(state) }] }
534
- });
578
+ await saveStateFile(state);
535
579
  } catch {}
580
+ if (getConfig().navigation === "auto") {
581
+ await navigateToSession(ctx, state.parentSessionID, sessionID);
582
+ }
536
583
  await ctx.client.tui.showToast({
537
584
  body: { message: "Roundtable concluded", variant: "success" }
538
585
  });
@@ -569,7 +616,7 @@ async function processNextTurn(ctx, state) {
569
616
  const userMsgs = messages.filter((m) => m.info.role === "user");
570
617
  for (const msg of userMsgs) {
571
618
  for (const part of msg.parts) {
572
- if (part.type === "text" && !part.text.includes("[ROUNDTABLE META")) {
619
+ if (part.type === "text") {
573
620
  if (!state.userInterjections.includes(part.text)) {
574
621
  state.userInterjections.push(part.text);
575
622
  }
@@ -597,6 +644,7 @@ async function processNextTurn(ctx, state) {
597
644
  hasError: response === null
598
645
  };
599
646
  state.history.push(entry);
647
+ await saveStateFile(state);
600
648
  await ctx.client.app.log({
601
649
  body: {
602
650
  service: "roundtable",
@@ -657,6 +705,7 @@ async function processNextTurn(ctx, state) {
657
705
  toolCalls: [],
658
706
  hasError: summary === null
659
707
  });
708
+ await saveStateFile(state);
660
709
  state.phase = "done";
661
710
  await finalizeRoundtable(ctx, state);
662
711
  }
@@ -664,6 +713,7 @@ async function processNextTurn(ctx, state) {
664
713
  async function handleAgentError(ctx, state, event) {
665
714
  if (state.phase === "observing" || state.phase === "done") {
666
715
  state.phase = "aborted";
716
+ await saveStateFile(state);
667
717
  await finalizeRoundtable(ctx, state);
668
718
  return;
669
719
  }
@@ -694,6 +744,7 @@ async function handleAgentError(ctx, state, event) {
694
744
  }
695
745
  });
696
746
  } catch {}
747
+ await saveStateFile(state);
697
748
  await sendToAgent(ctx, state);
698
749
  } else if (state.currentRound + 1 < state.totalRounds) {
699
750
  state.currentRound++;
@@ -708,6 +759,7 @@ async function handleAgentError(ctx, state, event) {
708
759
  }
709
760
  });
710
761
  } catch {}
762
+ await saveStateFile(state);
711
763
  await sendToAgent(ctx, state);
712
764
  } else {
713
765
  state.phase = "aborted";
@@ -716,6 +768,7 @@ async function handleAgentError(ctx, state, event) {
716
768
  body: { message: "All agents failed — roundtable aborted", variant: "error" }
717
769
  });
718
770
  } catch {}
771
+ await saveStateFile(state);
719
772
  await finalizeRoundtable(ctx, state);
720
773
  }
721
774
  }
@@ -725,6 +778,7 @@ async function handleSessionDeleted(ctx, state, deletedSessionID) {
725
778
  clearTimeout(handle);
726
779
  timeoutHandles.delete(state.sessionID);
727
780
  }
781
+ await deleteStateFile(state.sessionID);
728
782
  if (deletedSessionID === state.sessionID) {
729
783
  state.phase = "aborted";
730
784
  const partialSummary = buildConsolidatedSummary(state);
@@ -780,47 +834,15 @@ async function extendRoundtable(ctx, args, _toolCtx) {
780
834
  } catch {
781
835
  return `Error: Session #${sessionID} not found or inaccessible. Cannot extend.`;
782
836
  }
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) {
837
+ const originalState = await loadStateFile(sessionID);
838
+ if (!originalState) {
791
839
  return [
792
- `Error: No roundtable state found in session #${sessionID}.`,
793
- "This session is not a roundtable or the state was lost during compaction."
840
+ `Error: No roundtable state found for session #${sessionID}.`,
841
+ "This session is not a roundtable or the state file was deleted."
794
842
  ].join(`
795
843
  `);
796
844
  }
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 {
845
+ if (originalState.phase !== "done") {
824
846
  return [
825
847
  `Error: Roundtable #${sessionID} is in state "${originalState.phase}" and cannot be extended.`
826
848
  ].join(`
@@ -862,14 +884,12 @@ async function extendRoundtable(ctx, args, _toolCtx) {
862
884
  errors: [...originalState.errors],
863
885
  createdAt: Date.now(),
864
886
  currentGeneration: 0,
865
- userInterjections: [...originalState.userInterjections ?? []]
887
+ userInterjections: [...originalState.userInterjections ?? []],
888
+ observerPrompt: args.observerPrompt ?? originalState.observerPrompt
866
889
  };
867
890
  states.set(sessionID, newState);
868
891
  try {
869
- await ctx.client.session.prompt({
870
- path: { id: sessionID },
871
- body: { noReply: true, parts: [{ type: "text", text: serializeState(newState) }] }
872
- });
892
+ await saveStateFile(newState);
873
893
  const agentList = originalState.agents.join(" vs ");
874
894
  await ctx.client.session.update({
875
895
  path: { id: sessionID },
@@ -881,7 +901,7 @@ async function extendRoundtable(ctx, args, _toolCtx) {
881
901
  noReply: true,
882
902
  parts: [{
883
903
  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: "..."})`
904
+ text: `⚙ Roundtable extended #${sessionID} • +${args.rounds} round(s)`
885
905
  }]
886
906
  }
887
907
  });
@@ -954,16 +974,19 @@ var RoundtablePlugin = async (ctx) => {
954
974
 
955
975
  ` + "Note: this tool injects system-level prompts into each agent's context during " + `the debate (role-setting, topic, turn routing, and lifecycle signals).
956
976
 
977
+ ` + "IMPORTANT: This tool requires 2+ agents. For single-agent tasks, use a regular " + `session prompt instead — do NOT use roundtable.
978
+
957
979
  ` + "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
980
 
959
981
  ` + "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
982
  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"]'),
983
+ agents: tool.schema.array(tool.schema.string()).min(2).describe("Agent names in speaking order (minimum 2). For single-agent tasks, use a regular " + "session — do NOT use roundtable. Choose agents based on their expertise — " + 'each brings a different perspective. Example: ["pm", "dev", "rv"]'),
962
984
  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
985
  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
986
  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
987
  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}".')
988
+ 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}".'),
989
+ observerPrompt: tool.schema.string().optional().describe("Override the default observer consolidation prompt. Use this to control " + "the format and focus of the final summary — e.g., ask the observer to " + "save a detailed report to file, focus on technical decisions only, " + "output as JSON, extract action items, etc. " + "If omitted, the default observer prompt is used (executive summary).")
967
990
  },
968
991
  async execute(args, toolCtx) {
969
992
  try {
@@ -1020,6 +1043,22 @@ var RoundtablePlugin = async (ctx) => {
1020
1043
  return "Error: Could not fetch agent list. The server might not be ready.";
1021
1044
  }
1022
1045
  }
1046
+ }),
1047
+ active_roundtables: tool({
1048
+ description: "Lists all active roundtables with their status. " + "Each entry shows session ID, agents, current round, and phase. " + "Returns clickable session IDs that can be used to navigate.",
1049
+ args: {},
1050
+ async execute() {
1051
+ const active = [];
1052
+ for (const [sid, s] of states) {
1053
+ const status = s.phase === "active" ? "debating" : s.phase === "observing" ? "consolidating" : s.phase === "done" ? "concluded" : s.phase;
1054
+ active.push(`- #${sid} · ${s.agents.join("→")} (R${s.currentRound + 1}/${s.totalRounds}) · ${status}`);
1055
+ }
1056
+ if (active.length === 0)
1057
+ return "No active roundtables.";
1058
+ return `Active roundtables:
1059
+ ${active.join(`
1060
+ `)}`;
1061
+ }
1023
1062
  })
1024
1063
  }
1025
1064
  };
@@ -1028,5 +1067,5 @@ export {
1028
1067
  RoundtablePlugin
1029
1068
  };
1030
1069
 
1031
- //# debugId=3293C14B588953DF64756E2164756E21
1070
+ //# debugId=C8BED17810CFAA4D64756E2164756E21
1032
1071
  //# sourceMappingURL=index.js.map