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.
@@ -0,0 +1,394 @@
1
+ """
2
+ Participant Agent
3
+ =================
4
+
5
+ Subprocess-based participant agent for discussions.
6
+ Each invocation creates a fresh ClaudeSDKClient, generates one response, and exits.
7
+
8
+ This solves the client reuse problem where max_turns=1 causes empty responses
9
+ on subsequent queries to the same client.
10
+
11
+ Usage:
12
+ python -m backend.services.participant_agent \
13
+ --participant-id 1 \
14
+ --participant-name "Claude A" \
15
+ --participant-role "Tech Lead" \
16
+ --data-file /tmp/context.json \
17
+ --mode speak
18
+
19
+ Output (JSON Lines to stdout):
20
+ {"type": "text", "content": "..."}
21
+ {"type": "tool_use", "tool": "Read", "input": "..."}
22
+ {"type": "response_complete", "full_content": "..."}
23
+ """
24
+
25
+ import argparse
26
+ import asyncio
27
+ import json
28
+ import logging
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ # Lazy import for ClaudeSDK - check availability at runtime
34
+ ClaudeSDKClient = None
35
+ ClaudeAgentOptions = None
36
+
37
+ def _ensure_claude_sdk():
38
+ """Ensure ClaudeSDK is available, exit with error if not."""
39
+ global ClaudeSDKClient, ClaudeAgentOptions
40
+ if ClaudeSDKClient is None:
41
+ try:
42
+ from claude_agent_sdk import ClaudeSDKClient as _Client, ClaudeAgentOptions as _Options
43
+ ClaudeSDKClient = _Client
44
+ ClaudeAgentOptions = _Options
45
+ except ImportError:
46
+ print(json.dumps({
47
+ "type": "error",
48
+ "content": "ClaudeCode SDK is not installed. Please run: pip install claude-agent-sdk"
49
+ }), flush=True)
50
+ sys.exit(1)
51
+
52
+ # Import meeting prompts - handle both module and standalone execution
53
+ try:
54
+ from .meeting_prompts import (
55
+ get_meeting_type_prompt,
56
+ get_language_instruction,
57
+ FACILITATOR_SYSTEM_PROMPT,
58
+ )
59
+ except ImportError:
60
+ # Running as standalone script - use direct import
61
+ import sys
62
+ from pathlib import Path
63
+ sys.path.insert(0, str(Path(__file__).parent))
64
+ from meeting_prompts import (
65
+ get_meeting_type_prompt,
66
+ get_language_instruction,
67
+ FACILITATOR_SYSTEM_PROMPT,
68
+ )
69
+
70
+ # Configure logging to stderr (stdout is reserved for JSON output)
71
+ logging.basicConfig(
72
+ level=logging.INFO,
73
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
74
+ stream=sys.stderr,
75
+ )
76
+ logger = logging.getLogger(__name__)
77
+
78
+ # Read-only tools allowed for discussion participants
79
+ READ_ONLY_TOOLS = [
80
+ "Read", # Read files
81
+ "Grep", # Search file contents
82
+ "Glob", # Find files by pattern
83
+ "WebFetch", # Fetch web content (read-only)
84
+ "WebSearch", # Search the web (read-only)
85
+ ]
86
+
87
+
88
+ def build_system_prompt(
89
+ name: str,
90
+ role: str,
91
+ context: str,
92
+ topic: str,
93
+ mode: str = "speak",
94
+ meeting_type: Optional[str] = None,
95
+ custom_meeting_description: str = "",
96
+ language: str = "ja",
97
+ is_facilitator: bool = False,
98
+ ) -> str:
99
+ """Build the system prompt for this participant."""
100
+ role_desc = role or "discussion participant"
101
+
102
+ # Get meeting type prompt and language instruction
103
+ # meeting_type is already a string, pass it directly
104
+ meeting_type_prompt = ""
105
+ if meeting_type:
106
+ meeting_type_prompt = get_meeting_type_prompt(meeting_type, custom_meeting_description)
107
+
108
+ language_instruction = get_language_instruction(language)
109
+
110
+ # Facilitator closing prompt
111
+ if is_facilitator and mode == "speak":
112
+ return f"""You are {name}, the facilitator of this multi-Claude discussion room.
113
+
114
+ {language_instruction}
115
+
116
+ {FACILITATOR_SYSTEM_PROMPT}
117
+
118
+ {meeting_type_prompt}
119
+
120
+ ## Discussion Topic
121
+ {topic}
122
+
123
+ ## Your Task
124
+ You are generating the CLOSING message for this discussion.
125
+ Please summarize:
126
+ 1. The key discussion points
127
+ 2. Decisions made (if any)
128
+ 3. Next actions or open items
129
+
130
+ ## Response Format
131
+ - Start with [{name}]:
132
+ - Be concise but comprehensive
133
+ - Thank the participants at the end
134
+ """
135
+
136
+ if mode == "prepare":
137
+ # Preparation mode: gather information but don't speak yet
138
+ prompt = f"""You are {name}, a {role_desc} preparing for a multi-Claude discussion.
139
+
140
+ {language_instruction}
141
+
142
+ ## PREPARATION MODE
143
+ You are preparing to contribute to a discussion. Your task is to:
144
+ 1. Read relevant files to understand the codebase
145
+ 2. Search for information that will be useful for the discussion
146
+ 3. Take notes on key findings
147
+
148
+ **DO NOT** generate a discussion response yet. Instead, output a summary of your findings
149
+ that will help you when it's your turn to speak.
150
+
151
+ {meeting_type_prompt}
152
+
153
+ ## Discussion Topic
154
+ {topic}
155
+
156
+ ## Your Background Context
157
+ {context if context else "(No prior context provided)"}
158
+
159
+ ## Output Format
160
+ Summarize your findings in 2-3 paragraphs that will help you contribute to the discussion.
161
+ Focus on technical details, code patterns, and insights relevant to the topic.
162
+ """
163
+ else:
164
+ # Speaking mode: generate a discussion response
165
+ prompt = f"""You are {name}, a {role_desc} in a multi-Claude discussion room.
166
+
167
+ {language_instruction}
168
+
169
+ ## CRITICAL: READ-ONLY DISCUSSION MODE
170
+ This is a DISCUSSION-ONLY environment.
171
+
172
+ **Allowed Actions:**
173
+ - Read files to understand code structure
174
+ - Search code using Grep and Glob
175
+ - Fetch web content for reference
176
+ - Discuss, analyze, and share insights
177
+
178
+ **STRICTLY FORBIDDEN:**
179
+ - Writing, editing, or modifying any files
180
+ - Executing any bash commands
181
+ - Implementing any features or fixes
182
+ - Making any changes to the codebase
183
+
184
+ Your role is to discuss, analyze, share insights, and exchange ideas with other participants.
185
+ You may read files to support your discussion points, but you must NEVER modify anything.
186
+ If asked to implement or modify anything, politely decline and redirect to discussing the approach instead.
187
+
188
+ {meeting_type_prompt}
189
+
190
+ ## Discussion Topic
191
+ {topic}
192
+
193
+ ## Your Background Context
194
+ The following is conversation history from your previous work that is relevant to this discussion:
195
+
196
+ {context if context else "(No prior context provided)"}
197
+
198
+ ## Discussion Guidelines
199
+ 1. Build on what others have said - reference their points by name
200
+ 2. Share insights from your background context when relevant
201
+ 3. Be concise but thorough - aim for 2-4 paragraphs per response
202
+ 4. If you disagree, explain your reasoning respectfully
203
+ 5. Ask clarifying questions when needed
204
+ 6. When the discussion seems complete, suggest concrete next steps or conclusions
205
+ 7. Focus on analysis, architecture discussions, code review feedback, and sharing knowledge
206
+ 8. Do NOT offer to implement anything - only discuss approaches and trade-offs
207
+
208
+ ## Response Format
209
+ - Start your response with [{name}]:
210
+ - Write in a conversational but professional tone
211
+ - Focus on substance over pleasantries
212
+ """
213
+ return prompt
214
+
215
+
216
+ def emit_json(data: dict) -> None:
217
+ """Emit a JSON line to stdout."""
218
+ print(json.dumps(data, ensure_ascii=False), flush=True)
219
+
220
+
221
+ async def run_participant_agent(
222
+ participant_id: int,
223
+ participant_name: str,
224
+ participant_role: str,
225
+ room_topic: str,
226
+ context_text: str,
227
+ conversation_history: str,
228
+ cwd: Optional[str] = None,
229
+ mode: str = "speak",
230
+ preparation_notes: str = "",
231
+ meeting_type: Optional[str] = None,
232
+ custom_meeting_description: str = "",
233
+ language: str = "ja",
234
+ is_facilitator: bool = False,
235
+ ) -> None:
236
+ """
237
+ Run the participant agent to generate one response.
238
+
239
+ Args:
240
+ participant_id: Database ID of the participant
241
+ participant_name: Display name of the participant
242
+ participant_role: Role description
243
+ room_topic: Discussion topic
244
+ context_text: Background context from ClaudeCode history
245
+ conversation_history: Current conversation history
246
+ cwd: Working directory for file operations
247
+ mode: "speak" for generating response, "prepare" for preparation
248
+ preparation_notes: Notes from preparation phase (if any)
249
+ meeting_type: Type of meeting (from MeetingType enum)
250
+ custom_meeting_description: Custom description for "other" meeting type
251
+ language: Language for the discussion (ja or en)
252
+ is_facilitator: Whether this participant is the facilitator
253
+ """
254
+ # Ensure SDK is available
255
+ _ensure_claude_sdk()
256
+
257
+ logger.info(f"Starting participant agent: {participant_name} (mode={mode}, lang={language}, facilitator={is_facilitator})")
258
+
259
+ system_prompt = build_system_prompt(
260
+ name=participant_name,
261
+ role=participant_role,
262
+ context=context_text,
263
+ topic=room_topic,
264
+ mode=mode,
265
+ meeting_type=meeting_type,
266
+ custom_meeting_description=custom_meeting_description,
267
+ language=language,
268
+ is_facilitator=is_facilitator,
269
+ )
270
+
271
+ # Build the prompt based on mode
272
+ if mode == "prepare":
273
+ prompt = f"""## Discussion Topic
274
+ {room_topic}
275
+
276
+ ## Current Discussion (for context)
277
+ {conversation_history if conversation_history else "(Discussion not started yet)"}
278
+
279
+ Please analyze the codebase and prepare notes for your upcoming contribution to this discussion.
280
+ """
281
+ else:
282
+ prompt = f"""## Current Discussion
283
+ {conversation_history}
284
+
285
+ """
286
+ if preparation_notes:
287
+ prompt += f"""## Your Preparation Notes
288
+ {preparation_notes}
289
+
290
+ """
291
+ prompt += f"""Please provide your response to continue the discussion. Remember to start with [{participant_name}]:"""
292
+
293
+ try:
294
+ async with ClaudeSDKClient(
295
+ options=ClaudeAgentOptions(
296
+ model="claude-sonnet-4-20250514",
297
+ system_prompt=system_prompt,
298
+ max_turns=10, # Allow multiple tool uses within one response
299
+ allowed_tools=READ_ONLY_TOOLS,
300
+ permission_mode="bypassPermissions",
301
+ cwd=cwd,
302
+ )
303
+ ) as client:
304
+ await client.query(prompt)
305
+
306
+ full_response = ""
307
+ async for msg in client.receive_response():
308
+ msg_type = type(msg).__name__
309
+
310
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
311
+ for block in msg.content:
312
+ block_type = type(block).__name__
313
+
314
+ if block_type == "TextBlock" and hasattr(block, "text"):
315
+ text = block.text
316
+ full_response += text
317
+ emit_json({"type": "text", "content": text})
318
+
319
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
320
+ tool_name = block.name
321
+ tool_input = getattr(block, "input", {})
322
+ emit_json({
323
+ "type": "tool_use",
324
+ "tool": tool_name,
325
+ "input": str(tool_input)[:200], # Truncate for display
326
+ })
327
+
328
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
329
+ # Tool results
330
+ for block in msg.content:
331
+ block_type = type(block).__name__
332
+ if block_type == "ToolResultBlock":
333
+ is_error = getattr(block, "is_error", False)
334
+ if is_error:
335
+ emit_json({"type": "tool_error", "error": "Tool execution failed"})
336
+
337
+ # Emit completion
338
+ emit_json({
339
+ "type": "response_complete",
340
+ "full_content": full_response,
341
+ "mode": mode,
342
+ })
343
+
344
+ except Exception as e:
345
+ logger.error(f"Error in participant agent: {e}")
346
+ emit_json({
347
+ "type": "error",
348
+ "content": str(e),
349
+ })
350
+ sys.exit(1)
351
+
352
+
353
+ def main():
354
+ parser = argparse.ArgumentParser(description="Participant agent for discussions")
355
+ parser.add_argument("--participant-id", type=int, required=True, help="Participant database ID")
356
+ parser.add_argument("--participant-name", required=True, help="Participant display name")
357
+ parser.add_argument("--participant-role", default="", help="Participant role")
358
+ parser.add_argument("--data-file", required=True, help="JSON file with context data")
359
+ parser.add_argument("--cwd", default=None, help="Working directory for file operations")
360
+ parser.add_argument("--mode", choices=["speak", "prepare"], default="speak", help="Agent mode")
361
+ parser.add_argument("--meeting-type", default=None, help="Meeting type (from MeetingType enum)")
362
+ parser.add_argument("--language", default="ja", help="Language for discussion (ja or en)")
363
+ parser.add_argument("--is-facilitator", action="store_true", help="Whether this is a facilitator")
364
+
365
+ args = parser.parse_args()
366
+
367
+ # Load context data from file
368
+ try:
369
+ with open(args.data_file, "r", encoding="utf-8") as f:
370
+ data = json.load(f)
371
+ except Exception as e:
372
+ emit_json({"type": "error", "content": f"Failed to load data file: {e}"})
373
+ sys.exit(1)
374
+
375
+ # Run the agent
376
+ asyncio.run(run_participant_agent(
377
+ participant_id=args.participant_id,
378
+ participant_name=args.participant_name,
379
+ participant_role=args.participant_role,
380
+ room_topic=data.get("room_topic", ""),
381
+ context_text=data.get("context_text", ""),
382
+ conversation_history=data.get("conversation_history", ""),
383
+ cwd=args.cwd,
384
+ mode=args.mode,
385
+ preparation_notes=data.get("preparation_notes", ""),
386
+ meeting_type=args.meeting_type or data.get("meeting_type"),
387
+ custom_meeting_description=data.get("custom_meeting_description", ""),
388
+ language=args.language or data.get("language", "ja"),
389
+ is_facilitator=args.is_facilitator or data.get("is_facilitator", False),
390
+ ))
391
+
392
+
393
+ if __name__ == "__main__":
394
+ main()
backend/websocket.py ADDED
@@ -0,0 +1,292 @@
1
+ """
2
+ WebSocket Handler
3
+ =================
4
+
5
+ WebSocket endpoint for real-time discussion updates.
6
+ Supports both serial and parallel discussion orchestration.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ from typing import Dict, List
13
+
14
+ from fastapi import WebSocket, WebSocketDisconnect
15
+
16
+ from .models.database import (
17
+ DiscussionRoom,
18
+ RoomStatus,
19
+ get_session_maker,
20
+ )
21
+ from .services.parallel_orchestrator import (
22
+ ParallelDiscussionOrchestrator,
23
+ get_parallel_orchestrator,
24
+ register_parallel_orchestrator,
25
+ unregister_parallel_orchestrator,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Active WebSocket connections per room
31
+ _connections: Dict[int, List[WebSocket]] = {}
32
+
33
+ # Type alias for orchestrator
34
+ Orchestrator = ParallelDiscussionOrchestrator
35
+
36
+
37
+ async def broadcast_to_room(room_id: int, message: dict):
38
+ """Broadcast a message to all connected clients in a room."""
39
+ if room_id not in _connections:
40
+ return
41
+
42
+ disconnected = []
43
+ for ws in _connections[room_id]:
44
+ try:
45
+ await ws.send_json(message)
46
+ except Exception:
47
+ disconnected.append(ws)
48
+
49
+ # Remove disconnected clients
50
+ for ws in disconnected:
51
+ if ws in _connections[room_id]:
52
+ _connections[room_id].remove(ws)
53
+
54
+
55
+ async def room_websocket(websocket: WebSocket, room_id: int):
56
+ """
57
+ WebSocket endpoint for a discussion room.
58
+
59
+ Handles:
60
+ - Connection management
61
+ - Starting discussions (with parallel preparation)
62
+ - Streaming updates to clients
63
+ - Moderator message injection
64
+ - Background activity notifications
65
+ """
66
+ await websocket.accept()
67
+
68
+ # Get database session
69
+ SessionMaker = get_session_maker()
70
+ db = SessionMaker()
71
+
72
+ try:
73
+ # Get room
74
+ room = db.query(DiscussionRoom).filter(
75
+ DiscussionRoom.id == room_id
76
+ ).first()
77
+
78
+ if not room:
79
+ await websocket.send_json({
80
+ "type": "error",
81
+ "content": "Room not found"
82
+ })
83
+ await websocket.close()
84
+ return
85
+
86
+ # Register connection
87
+ if room_id not in _connections:
88
+ _connections[room_id] = []
89
+ _connections[room_id].append(websocket)
90
+
91
+ logger.info(f"WebSocket connected to room {room_id}")
92
+
93
+ # Send initial state
94
+ await websocket.send_json({
95
+ "type": "room_state",
96
+ "room_id": room_id,
97
+ "status": room.status.value,
98
+ "current_turn": room.current_turn,
99
+ "max_turns": room.max_turns,
100
+ "participants": [
101
+ {
102
+ "id": p.id,
103
+ "name": p.name,
104
+ "role": p.role,
105
+ "color": p.color,
106
+ "is_speaking": p.is_speaking,
107
+ }
108
+ for p in room.participants
109
+ ],
110
+ })
111
+
112
+ # Discussion task (started manually via 'start' message)
113
+ discussion_task = None
114
+
115
+ # Keep connection alive and handle client messages
116
+ while True:
117
+ try:
118
+ data = await asyncio.wait_for(
119
+ websocket.receive_text(),
120
+ timeout=60.0
121
+ )
122
+ message = json.loads(data)
123
+
124
+ if message.get("type") == "ping":
125
+ await websocket.send_json({"type": "pong"})
126
+
127
+ elif message.get("type") == "start":
128
+ # Start discussion if not already running
129
+ if not get_parallel_orchestrator(room_id):
130
+ # Refresh room state
131
+ db.refresh(room)
132
+ if room.status in (RoomStatus.WAITING, RoomStatus.PAUSED):
133
+ discussion_task = asyncio.create_task(
134
+ run_discussion_with_broadcast(room_id, db)
135
+ )
136
+ await websocket.send_json({
137
+ "type": "discussion_starting"
138
+ })
139
+ else:
140
+ await websocket.send_json({
141
+ "type": "info",
142
+ "content": "Discussion already running"
143
+ })
144
+
145
+ elif message.get("type") == "pause":
146
+ orchestrator = get_parallel_orchestrator(room_id)
147
+ if orchestrator:
148
+ orchestrator.pause()
149
+ await broadcast_to_room(room_id, {
150
+ "type": "discussion_paused"
151
+ })
152
+
153
+ elif message.get("type") == "stop":
154
+ orchestrator = get_parallel_orchestrator(room_id)
155
+ if orchestrator:
156
+ orchestrator.stop()
157
+
158
+ elif message.get("type") == "moderate":
159
+ # Handle moderator message injection
160
+ content = message.get("content", "").strip()
161
+ if content:
162
+ from .models.database import DiscussionMessage
163
+ msg = DiscussionMessage(
164
+ room_id=room_id,
165
+ participant_id=None,
166
+ role="moderator",
167
+ content=content,
168
+ turn_number=room.current_turn,
169
+ )
170
+ db.add(msg)
171
+ db.commit()
172
+ db.refresh(msg)
173
+
174
+ await broadcast_to_room(room_id, {
175
+ "type": "moderator_message",
176
+ "message_id": msg.id,
177
+ "content": content,
178
+ "turn_number": msg.turn_number,
179
+ })
180
+
181
+ except asyncio.TimeoutError:
182
+ # Send ping to keep connection alive
183
+ try:
184
+ await websocket.send_json({"type": "ping"})
185
+ except Exception:
186
+ break
187
+
188
+ except WebSocketDisconnect:
189
+ break
190
+
191
+ except json.JSONDecodeError:
192
+ await websocket.send_json({
193
+ "type": "error",
194
+ "content": "Invalid JSON"
195
+ })
196
+
197
+ except Exception as e:
198
+ logger.error(f"WebSocket error: {e}")
199
+ break
200
+
201
+ except Exception as e:
202
+ logger.error(f"WebSocket handler error: {e}")
203
+
204
+ finally:
205
+ # Remove connection
206
+ if room_id in _connections:
207
+ _connections[room_id] = [
208
+ ws for ws in _connections[room_id] if ws != websocket
209
+ ]
210
+ if not _connections[room_id]:
211
+ del _connections[room_id]
212
+
213
+ db.close()
214
+ logger.info(f"WebSocket disconnected from room {room_id}")
215
+
216
+
217
+ async def run_discussion_with_broadcast(room_id: int, _db=None):
218
+ """
219
+ Run a discussion with parallel preparation and broadcast updates.
220
+
221
+ Uses ParallelDiscussionOrchestrator which allows participants to
222
+ prepare in the background while maintaining turn-based order.
223
+
224
+ Note: Creates its own database session to avoid session conflicts
225
+ with the WebSocket handler's session.
226
+ """
227
+ logger.info(f"Starting discussion broadcast for room {room_id}")
228
+
229
+ # Create a fresh session for the discussion to avoid session conflicts
230
+ SessionMaker = get_session_maker()
231
+ db = SessionMaker()
232
+
233
+ try:
234
+ # Get fresh room instance
235
+ room = db.query(DiscussionRoom).filter(
236
+ DiscussionRoom.id == room_id
237
+ ).first()
238
+
239
+ if not room:
240
+ logger.error(f"Room {room_id} not found")
241
+ await broadcast_to_room(room_id, {
242
+ "type": "error",
243
+ "content": "Room not found"
244
+ })
245
+ return
246
+
247
+ logger.info(f"Found room: {room.name}, participants: {len(room.participants)}")
248
+
249
+ if not room.participants:
250
+ logger.error(f"Room {room_id} has no participants")
251
+ await broadcast_to_room(room_id, {
252
+ "type": "error",
253
+ "content": "No participants in room"
254
+ })
255
+ return
256
+
257
+ # Use parallel orchestrator for background preparation support
258
+ orchestrator = ParallelDiscussionOrchestrator(room, db)
259
+ register_parallel_orchestrator(room_id, orchestrator)
260
+
261
+ try:
262
+ logger.info("Initializing participants...")
263
+ await orchestrator.initialize_participants()
264
+ logger.info("Participants initialized, starting discussion...")
265
+
266
+ async for event in orchestrator.run_discussion():
267
+ logger.debug(f"Broadcasting event: {event.get('type')}")
268
+ await broadcast_to_room(room_id, event)
269
+
270
+ logger.info("Discussion completed normally")
271
+
272
+ except Exception as e:
273
+ logger.error(f"Discussion error: {e}", exc_info=True)
274
+ await broadcast_to_room(room_id, {
275
+ "type": "error",
276
+ "content": str(e)
277
+ })
278
+
279
+ finally:
280
+ await orchestrator.cleanup()
281
+ unregister_parallel_orchestrator(room_id)
282
+
283
+ except Exception as e:
284
+ logger.error(f"Unexpected error in run_discussion_with_broadcast: {e}", exc_info=True)
285
+ await broadcast_to_room(room_id, {
286
+ "type": "error",
287
+ "content": f"Unexpected error: {str(e)}"
288
+ })
289
+
290
+ finally:
291
+ db.close()
292
+ logger.info(f"Discussion broadcast ended for room {room_id}")