cc-discussion 1.0.0__py3-none-any.whl
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.
- backend/__init__.py +1 -0
- backend/__main__.py +7 -0
- backend/cli.py +44 -0
- backend/main.py +167 -0
- backend/models/__init__.py +2 -0
- backend/models/database.py +244 -0
- backend/routers/__init__.py +3 -0
- backend/routers/history.py +202 -0
- backend/routers/rooms.py +377 -0
- backend/services/__init__.py +1 -0
- backend/services/codex_agent.py +357 -0
- backend/services/codex_history_reader.py +287 -0
- backend/services/discussion_orchestrator.py +461 -0
- backend/services/history_reader.py +455 -0
- backend/services/meeting_prompts.py +291 -0
- backend/services/parallel_orchestrator.py +908 -0
- backend/services/participant_agent.py +394 -0
- backend/websocket.py +292 -0
- cc_discussion-1.0.0.dist-info/METADATA +527 -0
- cc_discussion-1.0.0.dist-info/RECORD +23 -0
- cc_discussion-1.0.0.dist-info/WHEEL +5 -0
- cc_discussion-1.0.0.dist-info/entry_points.txt +2 -0
- cc_discussion-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discussion Orchestrator
|
|
3
|
+
=======================
|
|
4
|
+
|
|
5
|
+
Orchestrates multi-Claude discussions using subprocess-based agents.
|
|
6
|
+
Each participant agent runs in a separate process to avoid client reuse issues.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import AsyncGenerator, Optional, List, Callable
|
|
18
|
+
|
|
19
|
+
from .history_reader import (
|
|
20
|
+
load_session_history,
|
|
21
|
+
format_context_for_injection,
|
|
22
|
+
decode_project_id,
|
|
23
|
+
get_original_path_from_dir,
|
|
24
|
+
)
|
|
25
|
+
from ..models.database import (
|
|
26
|
+
DiscussionRoom,
|
|
27
|
+
RoomParticipant,
|
|
28
|
+
DiscussionMessage,
|
|
29
|
+
RoomStatus,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Path to the participant agent script
|
|
35
|
+
PARTICIPANT_AGENT_PATH = Path(__file__).parent / "participant_agent.py"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ParticipantClient:
|
|
39
|
+
"""
|
|
40
|
+
Manages a single Claude participant in the discussion.
|
|
41
|
+
|
|
42
|
+
Uses subprocess-based execution to avoid client reuse issues.
|
|
43
|
+
Each call to respond() spawns a fresh process with a new ClaudeSDKClient.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
participant: RoomParticipant,
|
|
49
|
+
context_text: str,
|
|
50
|
+
room_topic: str,
|
|
51
|
+
):
|
|
52
|
+
self.participant = participant
|
|
53
|
+
self.context_text = context_text
|
|
54
|
+
self.room_topic = room_topic
|
|
55
|
+
self.cwd: Optional[str] = None
|
|
56
|
+
self._process: Optional[subprocess.Popen] = None
|
|
57
|
+
|
|
58
|
+
def resolve_cwd(self) -> None:
|
|
59
|
+
"""Resolve the working directory from participant's project context."""
|
|
60
|
+
if self.participant.context_project_dir:
|
|
61
|
+
try:
|
|
62
|
+
# Decode the Base64 project ID to get internal directory path
|
|
63
|
+
internal_dir = Path(decode_project_id(self.participant.context_project_dir))
|
|
64
|
+
# Get the actual project path from session files
|
|
65
|
+
actual_path = get_original_path_from_dir(internal_dir)
|
|
66
|
+
if actual_path and Path(actual_path).exists():
|
|
67
|
+
self.cwd = actual_path
|
|
68
|
+
logger.info(f"Resolved cwd for {self.participant.name}: {self.cwd}")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.warning(f"Failed to resolve cwd for {self.participant.name}: {e}")
|
|
71
|
+
|
|
72
|
+
async def respond(
|
|
73
|
+
self,
|
|
74
|
+
conversation_history: str,
|
|
75
|
+
mode: str = "speak",
|
|
76
|
+
preparation_notes: str = "",
|
|
77
|
+
) -> AsyncGenerator[dict, None]:
|
|
78
|
+
"""
|
|
79
|
+
Generate a response by spawning a subprocess.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
conversation_history: Current conversation history
|
|
83
|
+
mode: "speak" for generating response, "prepare" for preparation
|
|
84
|
+
preparation_notes: Notes from preparation phase (if any)
|
|
85
|
+
|
|
86
|
+
Yields:
|
|
87
|
+
Dict with type and content (text chunks, tool use, completion, etc.)
|
|
88
|
+
"""
|
|
89
|
+
# Create temporary file with context data
|
|
90
|
+
data = {
|
|
91
|
+
"room_topic": self.room_topic,
|
|
92
|
+
"context_text": self.context_text,
|
|
93
|
+
"conversation_history": conversation_history,
|
|
94
|
+
"preparation_notes": preparation_notes,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Write context to temp file
|
|
98
|
+
fd, data_file = tempfile.mkstemp(suffix=".json", prefix="participant_")
|
|
99
|
+
try:
|
|
100
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
101
|
+
json.dump(data, f, ensure_ascii=False)
|
|
102
|
+
|
|
103
|
+
# Build command
|
|
104
|
+
cmd = [
|
|
105
|
+
sys.executable, "-u",
|
|
106
|
+
str(PARTICIPANT_AGENT_PATH),
|
|
107
|
+
"--participant-id", str(self.participant.id),
|
|
108
|
+
"--participant-name", self.participant.name,
|
|
109
|
+
"--participant-role", self.participant.role or "",
|
|
110
|
+
"--data-file", data_file,
|
|
111
|
+
"--mode", mode,
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
if self.cwd:
|
|
115
|
+
cmd.extend(["--cwd", self.cwd])
|
|
116
|
+
|
|
117
|
+
logger.info(f"Spawning participant agent: {self.participant.name} (mode={mode})")
|
|
118
|
+
|
|
119
|
+
# Spawn subprocess
|
|
120
|
+
env = os.environ.copy()
|
|
121
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
122
|
+
|
|
123
|
+
self._process = subprocess.Popen(
|
|
124
|
+
cmd,
|
|
125
|
+
stdout=subprocess.PIPE,
|
|
126
|
+
stderr=subprocess.PIPE,
|
|
127
|
+
text=True,
|
|
128
|
+
encoding="utf-8",
|
|
129
|
+
errors="replace",
|
|
130
|
+
env=env,
|
|
131
|
+
cwd=str(Path(__file__).parent.parent.parent), # Project root
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Read stdout line by line (JSON Lines format)
|
|
135
|
+
full_response = ""
|
|
136
|
+
|
|
137
|
+
async def read_output():
|
|
138
|
+
nonlocal full_response
|
|
139
|
+
loop = asyncio.get_event_loop()
|
|
140
|
+
|
|
141
|
+
while True:
|
|
142
|
+
# Read line in executor to not block event loop
|
|
143
|
+
line = await loop.run_in_executor(
|
|
144
|
+
None, self._process.stdout.readline
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if not line:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
msg = json.loads(line)
|
|
156
|
+
yield msg
|
|
157
|
+
|
|
158
|
+
# Track full response
|
|
159
|
+
if msg.get("type") == "text":
|
|
160
|
+
full_response += msg.get("content", "")
|
|
161
|
+
elif msg.get("type") == "response_complete":
|
|
162
|
+
full_response = msg.get("full_content", full_response)
|
|
163
|
+
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
logger.warning(f"Non-JSON output from agent: {line}")
|
|
166
|
+
|
|
167
|
+
async for msg in read_output():
|
|
168
|
+
yield msg
|
|
169
|
+
|
|
170
|
+
# Wait for process to complete
|
|
171
|
+
await asyncio.get_event_loop().run_in_executor(
|
|
172
|
+
None, self._process.wait
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Check for errors
|
|
176
|
+
if self._process.returncode != 0:
|
|
177
|
+
stderr = self._process.stderr.read()
|
|
178
|
+
logger.error(f"Agent process failed: {stderr}")
|
|
179
|
+
if not full_response:
|
|
180
|
+
yield {
|
|
181
|
+
"type": "error",
|
|
182
|
+
"content": f"Agent process failed: {stderr[:500]}"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Ensure response_complete is yielded if not already
|
|
186
|
+
# (in case the subprocess didn't output it)
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error(f"Error running participant agent: {e}")
|
|
190
|
+
yield {"type": "error", "content": str(e)}
|
|
191
|
+
|
|
192
|
+
finally:
|
|
193
|
+
# Clean up temp file
|
|
194
|
+
try:
|
|
195
|
+
Path(data_file).unlink(missing_ok=True)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Clean up process
|
|
200
|
+
if self._process:
|
|
201
|
+
try:
|
|
202
|
+
self._process.kill()
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
self._process = None
|
|
206
|
+
|
|
207
|
+
def stop(self):
|
|
208
|
+
"""Stop the running process if any."""
|
|
209
|
+
if self._process:
|
|
210
|
+
try:
|
|
211
|
+
self._process.terminate()
|
|
212
|
+
self._process.wait(timeout=5)
|
|
213
|
+
except Exception:
|
|
214
|
+
try:
|
|
215
|
+
self._process.kill()
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
self._process = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class DiscussionOrchestrator:
|
|
222
|
+
"""Orchestrates a multi-Claude discussion."""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
room: DiscussionRoom,
|
|
227
|
+
db_session,
|
|
228
|
+
on_event: Optional[Callable] = None,
|
|
229
|
+
):
|
|
230
|
+
self.room = room
|
|
231
|
+
self.db = db_session
|
|
232
|
+
self.participants: List[ParticipantClient] = []
|
|
233
|
+
self.current_speaker_idx = 0
|
|
234
|
+
self._running = False
|
|
235
|
+
self._paused = False
|
|
236
|
+
self.on_event = on_event
|
|
237
|
+
|
|
238
|
+
async def initialize_participants(self):
|
|
239
|
+
"""Initialize all participant clients with their contexts."""
|
|
240
|
+
for participant in self.room.participants:
|
|
241
|
+
# Load context from ClaudeCode history if specified
|
|
242
|
+
context_text = ""
|
|
243
|
+
if participant.context_project_dir and participant.context_session_id:
|
|
244
|
+
try:
|
|
245
|
+
messages = load_session_history(
|
|
246
|
+
participant.context_project_dir,
|
|
247
|
+
participant.context_session_id
|
|
248
|
+
)
|
|
249
|
+
context_text = format_context_for_injection(messages, max_chars=50000)
|
|
250
|
+
logger.info(
|
|
251
|
+
f"Loaded context for {participant.name}: "
|
|
252
|
+
f"{len(messages)} messages, {len(context_text)} chars"
|
|
253
|
+
)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.warning(
|
|
256
|
+
f"Failed to load context for {participant.name}: {e}"
|
|
257
|
+
)
|
|
258
|
+
context_text = participant.context_summary or ""
|
|
259
|
+
elif participant.context_summary:
|
|
260
|
+
context_text = participant.context_summary
|
|
261
|
+
|
|
262
|
+
client = ParticipantClient(
|
|
263
|
+
participant=participant,
|
|
264
|
+
context_text=context_text,
|
|
265
|
+
room_topic=self.room.topic or "General discussion",
|
|
266
|
+
)
|
|
267
|
+
# Resolve working directory
|
|
268
|
+
client.resolve_cwd()
|
|
269
|
+
self.participants.append(client)
|
|
270
|
+
|
|
271
|
+
logger.info(f"Initialized {len(self.participants)} participants")
|
|
272
|
+
|
|
273
|
+
def _build_conversation_history(self) -> str:
|
|
274
|
+
"""Build conversation history string from all messages."""
|
|
275
|
+
messages = self.db.query(DiscussionMessage).filter(
|
|
276
|
+
DiscussionMessage.room_id == self.room.id
|
|
277
|
+
).order_by(DiscussionMessage.created_at).all()
|
|
278
|
+
|
|
279
|
+
lines = []
|
|
280
|
+
for msg in messages:
|
|
281
|
+
if msg.role == "system":
|
|
282
|
+
lines.append(f"[System]: {msg.content}\n")
|
|
283
|
+
elif msg.role == "moderator":
|
|
284
|
+
lines.append(f"[Moderator]: {msg.content}\n")
|
|
285
|
+
else:
|
|
286
|
+
# Find participant name
|
|
287
|
+
participant = next(
|
|
288
|
+
(p for p in self.room.participants if p.id == msg.participant_id),
|
|
289
|
+
None
|
|
290
|
+
)
|
|
291
|
+
name = participant.name if participant else "Unknown"
|
|
292
|
+
lines.append(f"[{name}]: {msg.content}\n")
|
|
293
|
+
|
|
294
|
+
return "\n".join(lines)
|
|
295
|
+
|
|
296
|
+
async def run_turn(self) -> AsyncGenerator[dict, None]:
|
|
297
|
+
"""Run a single turn of discussion."""
|
|
298
|
+
if not self.participants:
|
|
299
|
+
yield {"type": "error", "content": "No participants initialized"}
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Get current speaker
|
|
303
|
+
speaker = self.participants[self.current_speaker_idx]
|
|
304
|
+
participant = speaker.participant
|
|
305
|
+
|
|
306
|
+
yield {
|
|
307
|
+
"type": "turn_start",
|
|
308
|
+
"participant_id": participant.id,
|
|
309
|
+
"participant_name": participant.name,
|
|
310
|
+
"turn_number": self.room.current_turn + 1,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Mark as speaking
|
|
314
|
+
participant.is_speaking = True
|
|
315
|
+
self.db.commit()
|
|
316
|
+
|
|
317
|
+
# Build history and get response
|
|
318
|
+
history = self._build_conversation_history()
|
|
319
|
+
full_content = ""
|
|
320
|
+
|
|
321
|
+
async for chunk in speaker.respond(history):
|
|
322
|
+
if chunk["type"] == "text":
|
|
323
|
+
yield {
|
|
324
|
+
"type": "text",
|
|
325
|
+
"content": chunk["content"],
|
|
326
|
+
"participant_id": participant.id,
|
|
327
|
+
}
|
|
328
|
+
elif chunk["type"] == "tool_use":
|
|
329
|
+
yield {
|
|
330
|
+
"type": "tool_use",
|
|
331
|
+
"tool": chunk.get("tool"),
|
|
332
|
+
"input": chunk.get("input"),
|
|
333
|
+
"participant_id": participant.id,
|
|
334
|
+
}
|
|
335
|
+
elif chunk["type"] == "response_complete":
|
|
336
|
+
full_content = chunk["full_content"]
|
|
337
|
+
elif chunk["type"] == "error":
|
|
338
|
+
yield {
|
|
339
|
+
"type": "error",
|
|
340
|
+
"content": chunk["content"],
|
|
341
|
+
"participant_id": participant.id,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
# Save message to database
|
|
345
|
+
message = DiscussionMessage(
|
|
346
|
+
room_id=self.room.id,
|
|
347
|
+
participant_id=participant.id,
|
|
348
|
+
role="participant",
|
|
349
|
+
content=full_content,
|
|
350
|
+
turn_number=self.room.current_turn + 1,
|
|
351
|
+
)
|
|
352
|
+
self.db.add(message)
|
|
353
|
+
|
|
354
|
+
# Update state
|
|
355
|
+
participant.is_speaking = False
|
|
356
|
+
participant.message_count += 1
|
|
357
|
+
self.room.current_turn += 1
|
|
358
|
+
self.current_speaker_idx = (
|
|
359
|
+
self.current_speaker_idx + 1
|
|
360
|
+
) % len(self.participants)
|
|
361
|
+
self.db.commit()
|
|
362
|
+
|
|
363
|
+
yield {
|
|
364
|
+
"type": "turn_complete",
|
|
365
|
+
"participant_id": participant.id,
|
|
366
|
+
"message_id": message.id,
|
|
367
|
+
"turn_number": self.room.current_turn,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async def run_discussion(self) -> AsyncGenerator[dict, None]:
|
|
371
|
+
"""Run the full discussion until max_turns or completion."""
|
|
372
|
+
self._running = True
|
|
373
|
+
self._paused = False
|
|
374
|
+
self.room.status = RoomStatus.ACTIVE
|
|
375
|
+
self.db.commit()
|
|
376
|
+
|
|
377
|
+
yield {
|
|
378
|
+
"type": "discussion_start",
|
|
379
|
+
"room_id": self.room.id,
|
|
380
|
+
"max_turns": self.room.max_turns,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
while (
|
|
384
|
+
self._running
|
|
385
|
+
and not self._paused
|
|
386
|
+
and self.room.current_turn < self.room.max_turns
|
|
387
|
+
):
|
|
388
|
+
async for chunk in self.run_turn():
|
|
389
|
+
yield chunk
|
|
390
|
+
|
|
391
|
+
# Small delay between turns for readability
|
|
392
|
+
await asyncio.sleep(1)
|
|
393
|
+
|
|
394
|
+
# Refresh room state from database (for pause/stop from outside)
|
|
395
|
+
self.db.refresh(self.room)
|
|
396
|
+
if self.room.status == RoomStatus.PAUSED:
|
|
397
|
+
self._paused = True
|
|
398
|
+
yield {"type": "discussion_paused", "turn": self.room.current_turn}
|
|
399
|
+
break
|
|
400
|
+
|
|
401
|
+
if not self._paused:
|
|
402
|
+
self.room.status = RoomStatus.COMPLETED
|
|
403
|
+
self.db.commit()
|
|
404
|
+
yield {
|
|
405
|
+
"type": "discussion_complete",
|
|
406
|
+
"total_turns": self.room.current_turn,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def pause(self):
|
|
410
|
+
"""Pause the discussion."""
|
|
411
|
+
self._paused = True
|
|
412
|
+
self.room.status = RoomStatus.PAUSED
|
|
413
|
+
self.db.commit()
|
|
414
|
+
|
|
415
|
+
# Stop any running participant process
|
|
416
|
+
for p in self.participants:
|
|
417
|
+
p.stop()
|
|
418
|
+
|
|
419
|
+
def stop(self):
|
|
420
|
+
"""Stop the discussion."""
|
|
421
|
+
self._running = False
|
|
422
|
+
self.room.status = RoomStatus.COMPLETED
|
|
423
|
+
self.db.commit()
|
|
424
|
+
|
|
425
|
+
# Stop any running participant process
|
|
426
|
+
for p in self.participants:
|
|
427
|
+
p.stop()
|
|
428
|
+
|
|
429
|
+
async def cleanup(self):
|
|
430
|
+
"""Clean up all participant clients."""
|
|
431
|
+
for client in self.participants:
|
|
432
|
+
client.stop()
|
|
433
|
+
self.participants.clear()
|
|
434
|
+
logger.info("Cleaned up all participant clients")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# Global registry of active orchestrators
|
|
438
|
+
_active_orchestrators: dict[int, DiscussionOrchestrator] = {}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_orchestrator(room_id: int) -> Optional[DiscussionOrchestrator]:
|
|
442
|
+
"""Get an active orchestrator by room ID."""
|
|
443
|
+
return _active_orchestrators.get(room_id)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def register_orchestrator(room_id: int, orchestrator: DiscussionOrchestrator):
|
|
447
|
+
"""Register an active orchestrator."""
|
|
448
|
+
_active_orchestrators[room_id] = orchestrator
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def unregister_orchestrator(room_id: int):
|
|
452
|
+
"""Unregister an orchestrator."""
|
|
453
|
+
if room_id in _active_orchestrators:
|
|
454
|
+
del _active_orchestrators[room_id]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def cleanup_all_orchestrators():
|
|
458
|
+
"""Clean up all active orchestrators."""
|
|
459
|
+
for room_id, orchestrator in list(_active_orchestrators.items()):
|
|
460
|
+
await orchestrator.cleanup()
|
|
461
|
+
_active_orchestrators.clear()
|