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 ADDED
@@ -0,0 +1 @@
1
+ """Claude Discussion Room Backend"""
backend/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ python -m backend で実行可能にする
3
+ """
4
+ from backend.cli import main
5
+
6
+ if __name__ == '__main__':
7
+ main()
backend/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ cc-discussion CLI
3
+ =================
4
+
5
+ uvx cc-discussion または python -m backend で実行
6
+
7
+ Usage:
8
+ cc-discussion # デフォルト設定で起動
9
+ cc-discussion --port 9000 # ポート指定
10
+ cc-discussion --no-browser # ブラウザを開かない
11
+ cc-discussion --reload # 開発モード(ホットリロード)
12
+ """
13
+ import webbrowser
14
+ from threading import Timer
15
+
16
+ import click
17
+ import uvicorn
18
+
19
+
20
+ @click.command()
21
+ @click.option('--host', default='127.0.0.1', help='バインドするホスト')
22
+ @click.option('--port', default=8888, type=int, help='バインドするポート')
23
+ @click.option('--no-browser', is_flag=True, help='ブラウザを自動で開かない')
24
+ @click.option('--reload', is_flag=True, help='ホットリロードを有効化(開発用)')
25
+ @click.version_option(version='1.0.0', prog_name='cc-discussion')
26
+ def main(host: str, port: int, no_browser: bool, reload: bool):
27
+ """Claude Discussion Room - マルチエージェント議論プラットフォーム"""
28
+ url = f"http://{host}:{port}"
29
+ click.echo(f"Starting cc-discussion on {url}")
30
+
31
+ # ブラウザを少し遅らせて開く(サーバー起動を待つ)
32
+ if not no_browser:
33
+ Timer(1.5, lambda: webbrowser.open(url)).start()
34
+
35
+ uvicorn.run(
36
+ "backend.main:app",
37
+ host=host,
38
+ port=port,
39
+ reload=reload,
40
+ )
41
+
42
+
43
+ if __name__ == '__main__':
44
+ main()
backend/main.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ FastAPI Main Application
3
+ ========================
4
+
5
+ Main entry point for the Claude Discussion Room server.
6
+ """
7
+
8
+ # Load environment variables from .env file first
9
+ from dotenv import load_dotenv
10
+ load_dotenv()
11
+
12
+ import asyncio
13
+ import logging
14
+ import sys
15
+ from contextlib import asynccontextmanager
16
+ from pathlib import Path
17
+
18
+ # Fix for Windows subprocess support
19
+ if sys.platform == "win32":
20
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
21
+
22
+ from fastapi import FastAPI, WebSocket
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import FileResponse
25
+ from fastapi.staticfiles import StaticFiles
26
+
27
+ from .routers import history_router, rooms_router
28
+ from .websocket import room_websocket
29
+ from .services.discussion_orchestrator import cleanup_all_orchestrators
30
+ from .models.database import get_engine
31
+
32
+ # Configure logging
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
36
+ )
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Paths - Support both development and packaged installation
40
+ ROOT_DIR = Path(__file__).parent.parent
41
+ UI_DIST_DIR = ROOT_DIR / "frontend" / "dist"
42
+
43
+ # Alternative locations for packaged installation
44
+ if not UI_DIST_DIR.exists():
45
+ # Try relative to the backend package
46
+ UI_DIST_DIR = Path(__file__).parent / ".." / "frontend" / "dist"
47
+ if not UI_DIST_DIR.exists():
48
+ UI_DIST_DIR = None # No frontend dist available
49
+
50
+
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ """Lifespan context manager for startup and shutdown."""
54
+ # Startup - initialize database
55
+ get_engine()
56
+ logger.info("Database initialized")
57
+
58
+ yield
59
+
60
+ # Shutdown - cleanup all orchestrators
61
+ await cleanup_all_orchestrators()
62
+ logger.info("Cleanup complete")
63
+
64
+
65
+ # Create FastAPI app
66
+ app = FastAPI(
67
+ title="Claude Discussion Room",
68
+ description="Multi-Claude discussion platform with ClaudeCode context",
69
+ version="1.0.0",
70
+ lifespan=lifespan,
71
+ )
72
+
73
+ # CORS
74
+ app.add_middleware(
75
+ CORSMiddleware,
76
+ allow_origins=[
77
+ "http://localhost:5173",
78
+ "http://127.0.0.1:5173",
79
+ "http://localhost:8888",
80
+ "http://127.0.0.1:8888",
81
+ "http://localhost:9000",
82
+ "http://127.0.0.1:9000",
83
+ ],
84
+ allow_credentials=True,
85
+ allow_methods=["*"],
86
+ allow_headers=["*"],
87
+ )
88
+
89
+ # Include routers
90
+ app.include_router(history_router)
91
+ app.include_router(rooms_router)
92
+
93
+
94
+ # WebSocket endpoint
95
+ @app.websocket("/ws/rooms/{room_id}")
96
+ async def websocket_endpoint(websocket: WebSocket, room_id: int):
97
+ """WebSocket endpoint for discussion room updates."""
98
+ await room_websocket(websocket, room_id)
99
+
100
+
101
+ # Health check
102
+ @app.get("/api/health")
103
+ async def health_check():
104
+ """Health check endpoint."""
105
+ return {"status": "healthy"}
106
+
107
+
108
+ # SDK availability check
109
+ @app.get("/api/config/available-agents")
110
+ async def get_available_agents():
111
+ """Check which agent SDKs are installed and available."""
112
+ available = []
113
+
114
+ # Check ClaudeCode SDK
115
+ try:
116
+ from claude_agent_sdk import ClaudeSDKClient
117
+ available.append("claude")
118
+ except ImportError:
119
+ pass
120
+
121
+ # Check Codex SDK
122
+ try:
123
+ from codex_sdk import Codex
124
+ available.append("codex")
125
+ except ImportError:
126
+ pass
127
+
128
+ return {"available_agents": available}
129
+
130
+
131
+ # Static file serving (Production)
132
+ if UI_DIST_DIR and UI_DIST_DIR.exists():
133
+ app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
134
+
135
+ @app.get("/")
136
+ async def serve_index():
137
+ """Serve the React app index.html."""
138
+ return FileResponse(UI_DIST_DIR / "index.html")
139
+
140
+ @app.get("/{path:path}")
141
+ async def serve_spa(path: str):
142
+ """Serve static files or fall back to index.html for SPA routing."""
143
+ if path.startswith("api/") or path.startswith("ws/"):
144
+ return {"error": "Not found"}
145
+
146
+ file_path = (UI_DIST_DIR / path).resolve()
147
+
148
+ # Prevent path traversal
149
+ try:
150
+ file_path.relative_to(UI_DIST_DIR.resolve())
151
+ except ValueError:
152
+ return FileResponse(UI_DIST_DIR / "index.html")
153
+
154
+ if file_path.exists() and file_path.is_file():
155
+ return FileResponse(file_path)
156
+
157
+ return FileResponse(UI_DIST_DIR / "index.html")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ import uvicorn
162
+ uvicorn.run(
163
+ "backend.main:app",
164
+ host="127.0.0.1",
165
+ port=8888,
166
+ reload=True,
167
+ )
@@ -0,0 +1,2 @@
1
+ """Database models for Claude Discussion Room."""
2
+ from .database import Base, DiscussionRoom, RoomParticipant, DiscussionMessage, RoomStatus
@@ -0,0 +1,244 @@
1
+ """
2
+ Database Models
3
+ ===============
4
+
5
+ SQLite database schema for discussion rooms using SQLAlchemy.
6
+ """
7
+
8
+ import enum
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Generator
12
+
13
+ from sqlalchemy import (
14
+ Boolean,
15
+ Column,
16
+ DateTime,
17
+ Enum,
18
+ ForeignKey,
19
+ Integer,
20
+ String,
21
+ Text,
22
+ create_engine,
23
+ )
24
+ from sqlalchemy.orm import DeclarativeBase, Session, relationship, sessionmaker
25
+ from sqlalchemy.types import JSON
26
+
27
+
28
+ def _utc_now() -> datetime:
29
+ """Return current UTC time."""
30
+ return datetime.now(timezone.utc)
31
+
32
+
33
+ class Base(DeclarativeBase):
34
+ """SQLAlchemy 2.0 style declarative base."""
35
+ pass
36
+
37
+
38
+ class RoomStatus(enum.Enum):
39
+ """Status of a discussion room."""
40
+ WAITING = "waiting" # Room created, waiting for discussion to start
41
+ ACTIVE = "active" # Discussion in progress
42
+ PAUSED = "paused" # Discussion paused
43
+ COMPLETED = "completed" # Discussion ended
44
+
45
+
46
+ class MeetingType(enum.Enum):
47
+ """会議タイプ"""
48
+ PROGRESS_CHECK = "progress_check" # 1. 進捗・状況確認
49
+ SPEC_ALIGNMENT = "spec_alignment" # 2. 要件・仕様の認識合わせ
50
+ TECHNICAL_REVIEW = "technical_review" # 3. 技術検討・設計判断
51
+ ISSUE_RESOLUTION = "issue_resolution" # 4. 課題・不具合対応
52
+ REVIEW = "review" # 5. レビュー
53
+ PLANNING = "planning" # 6. 計画・タスク整理
54
+ RELEASE_OPS = "release_ops" # 7. リリース・運用判断
55
+ RETROSPECTIVE = "retrospective" # 8. 改善・振り返り
56
+ OTHER = "other" # 9. その他(カスタム)
57
+
58
+
59
+ class AgentType(enum.Enum):
60
+ """エージェントタイプ"""
61
+ CLAUDE = "claude"
62
+ CODEX = "codex"
63
+
64
+
65
+ class DiscussionRoom(Base):
66
+ """A discussion room where multiple Claudes converse."""
67
+ __tablename__ = "discussion_rooms"
68
+
69
+ id = Column(Integer, primary_key=True, index=True)
70
+ name = Column(String(200), nullable=False)
71
+ topic = Column(Text, nullable=True)
72
+ status = Column(
73
+ Enum(RoomStatus, native_enum=False, values_callable=lambda x: [e.value for e in x]),
74
+ default=RoomStatus.WAITING
75
+ )
76
+ max_turns = Column(Integer, default=20)
77
+ current_turn = Column(Integer, default=0)
78
+ created_at = Column(DateTime, default=_utc_now)
79
+ updated_at = Column(DateTime, default=_utc_now, onupdate=_utc_now)
80
+
81
+ # Meeting type settings
82
+ meeting_type = Column(
83
+ Enum(MeetingType, native_enum=False, values_callable=lambda x: [e.value for e in x]),
84
+ default=MeetingType.TECHNICAL_REVIEW
85
+ )
86
+ custom_meeting_description = Column(Text, nullable=True) # "その他"選択時のカスタム説明
87
+ language = Column(String(10), default="ja")
88
+
89
+ participants = relationship(
90
+ "RoomParticipant",
91
+ back_populates="room",
92
+ cascade="all, delete-orphan"
93
+ )
94
+ messages = relationship(
95
+ "DiscussionMessage",
96
+ back_populates="room",
97
+ cascade="all, delete-orphan"
98
+ )
99
+
100
+ def to_dict(self) -> dict:
101
+ """Convert to dictionary for JSON serialization."""
102
+ return {
103
+ "id": self.id,
104
+ "name": self.name,
105
+ "topic": self.topic,
106
+ "status": self.status.value if self.status else "waiting",
107
+ "max_turns": self.max_turns,
108
+ "current_turn": self.current_turn,
109
+ "meeting_type": self.meeting_type.value if self.meeting_type else "technical_review",
110
+ "custom_meeting_description": self.custom_meeting_description,
111
+ "language": self.language or "ja",
112
+ "created_at": self.created_at.isoformat() if self.created_at else None,
113
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
114
+ "participant_count": len(self.participants) if self.participants else 0,
115
+ }
116
+
117
+
118
+ class RoomParticipant(Base):
119
+ """A Claude participant in a discussion room."""
120
+ __tablename__ = "room_participants"
121
+
122
+ id = Column(Integer, primary_key=True, index=True)
123
+ room_id = Column(Integer, ForeignKey("discussion_rooms.id"), nullable=False, index=True)
124
+
125
+ # Participant identity
126
+ name = Column(String(50), nullable=False)
127
+ role = Column(String(100), nullable=True)
128
+ color = Column(String(7), default="#6366f1")
129
+
130
+ # Context injection from ClaudeCode history
131
+ context_project_dir = Column(String(500), nullable=True)
132
+ context_session_id = Column(String(100), nullable=True)
133
+ context_summary = Column(Text, nullable=True)
134
+
135
+ # State
136
+ is_speaking = Column(Boolean, default=False)
137
+ message_count = Column(Integer, default=0)
138
+
139
+ # Agent settings
140
+ is_facilitator = Column(Boolean, default=False)
141
+ agent_type = Column(
142
+ Enum(AgentType, native_enum=False, values_callable=lambda x: [e.value for e in x]),
143
+ default=AgentType.CLAUDE
144
+ )
145
+
146
+ room = relationship("DiscussionRoom", back_populates="participants")
147
+
148
+ def to_dict(self) -> dict:
149
+ """Convert to dictionary for JSON serialization."""
150
+ return {
151
+ "id": self.id,
152
+ "room_id": self.room_id,
153
+ "name": self.name,
154
+ "role": self.role,
155
+ "color": self.color,
156
+ "context_project_dir": self.context_project_dir,
157
+ "context_session_id": self.context_session_id,
158
+ "has_context": bool(self.context_session_id),
159
+ "is_speaking": self.is_speaking,
160
+ "message_count": self.message_count,
161
+ "is_facilitator": self.is_facilitator or False,
162
+ "agent_type": self.agent_type.value if self.agent_type else "claude",
163
+ }
164
+
165
+
166
+ class DiscussionMessage(Base):
167
+ """A message in the discussion."""
168
+ __tablename__ = "discussion_messages"
169
+
170
+ id = Column(Integer, primary_key=True, index=True)
171
+ room_id = Column(Integer, ForeignKey("discussion_rooms.id"), nullable=False, index=True)
172
+ participant_id = Column(Integer, ForeignKey("room_participants.id"), nullable=True)
173
+
174
+ role = Column(String(20), nullable=False) # "system" | "participant" | "moderator"
175
+ content = Column(Text, nullable=False)
176
+ extra_data = Column(JSON, nullable=True) # Tool calls, thinking, etc.
177
+
178
+ turn_number = Column(Integer, nullable=False)
179
+ created_at = Column(DateTime, default=_utc_now)
180
+
181
+ room = relationship("DiscussionRoom", back_populates="messages")
182
+
183
+ def to_dict(self) -> dict:
184
+ """Convert to dictionary for JSON serialization."""
185
+ return {
186
+ "id": self.id,
187
+ "room_id": self.room_id,
188
+ "participant_id": self.participant_id,
189
+ "role": self.role,
190
+ "content": self.content,
191
+ "extra_data": self.extra_data,
192
+ "turn_number": self.turn_number,
193
+ "created_at": self.created_at.isoformat() if self.created_at else None,
194
+ }
195
+
196
+
197
+ # Database setup
198
+ DATABASE_PATH = Path(__file__).parent.parent.parent / "discussion.db"
199
+
200
+
201
+ def get_database_url() -> str:
202
+ """Return the SQLAlchemy database URL."""
203
+ return f"sqlite:///{DATABASE_PATH.as_posix()}"
204
+
205
+
206
+ _engine = None
207
+ _session_maker = None
208
+
209
+
210
+ def get_engine():
211
+ """Get or create the database engine."""
212
+ global _engine
213
+ if _engine is None:
214
+ _engine = create_engine(
215
+ get_database_url(),
216
+ connect_args={"check_same_thread": False}
217
+ )
218
+ Base.metadata.create_all(bind=_engine)
219
+ return _engine
220
+
221
+
222
+ def get_session_maker():
223
+ """Get or create the session maker."""
224
+ global _session_maker
225
+ if _session_maker is None:
226
+ _session_maker = sessionmaker(
227
+ autocommit=False,
228
+ autoflush=False,
229
+ bind=get_engine()
230
+ )
231
+ return _session_maker
232
+
233
+
234
+ def get_db() -> Generator[Session, None, None]:
235
+ """Dependency for FastAPI to get database session."""
236
+ SessionLocal = get_session_maker()
237
+ db = SessionLocal()
238
+ try:
239
+ yield db
240
+ except Exception:
241
+ db.rollback()
242
+ raise
243
+ finally:
244
+ db.close()
@@ -0,0 +1,3 @@
1
+ """API Routers for Claude Discussion Room."""
2
+ from .history import router as history_router
3
+ from .rooms import router as rooms_router
@@ -0,0 +1,202 @@
1
+ """
2
+ History Router
3
+ ==============
4
+
5
+ API endpoints for browsing ClaudeCode conversation history.
6
+ """
7
+
8
+ from typing import Any, List, Optional
9
+
10
+ from fastapi import APIRouter, HTTPException
11
+ from pydantic import BaseModel
12
+
13
+ from ..services.history_reader import (
14
+ list_projects,
15
+ list_sessions,
16
+ load_session_history,
17
+ decode_session_id,
18
+ )
19
+ from ..services.codex_history_reader import (
20
+ list_codex_projects,
21
+ list_codex_sessions,
22
+ )
23
+
24
+ router = APIRouter(prefix="/api/history", tags=["history"])
25
+
26
+
27
+ class ProjectResponse(BaseModel):
28
+ """Response model for a project."""
29
+ id: str
30
+ name: str
31
+ path: str
32
+ last_modified_at: str
33
+
34
+
35
+ class SessionResponse(BaseModel):
36
+ """Response model for a session."""
37
+ id: str
38
+ jsonl_file_path: str
39
+ last_modified_at: str
40
+ message_count: int
41
+ first_user_message: Optional[str]
42
+
43
+
44
+ class ToolCall(BaseModel):
45
+ """Tool call model."""
46
+ id: Optional[str]
47
+ name: Optional[str]
48
+ input: dict
49
+
50
+
51
+ class ToolResult(BaseModel):
52
+ """Tool result model."""
53
+ tool_use_id: Optional[str]
54
+ content: Any
55
+ is_error: bool = False
56
+
57
+
58
+ class ConversationResponse(BaseModel):
59
+ """Response model for a conversation message."""
60
+ type: str
61
+ uuid: str
62
+ timestamp: str
63
+ content: Any
64
+ is_sidechain: bool
65
+ parent_uuid: Optional[str]
66
+ tool_calls: List[ToolCall]
67
+ tool_results: List[ToolResult]
68
+
69
+
70
+ class SessionDetailResponse(BaseModel):
71
+ """Response model for session detail."""
72
+ id: str
73
+ jsonl_file_path: str
74
+ conversations: List[ConversationResponse]
75
+
76
+
77
+ @router.get("/projects", response_model=List[ProjectResponse])
78
+ async def get_claude_projects():
79
+ """List all ClaudeCode projects with history."""
80
+ projects = list_projects()
81
+ return [
82
+ ProjectResponse(
83
+ id=p.id,
84
+ name=p.name,
85
+ path=p.path,
86
+ last_modified_at=p.last_modified_at.isoformat(),
87
+ )
88
+ for p in projects
89
+ ]
90
+
91
+
92
+ @router.get("/projects/{project_id}/sessions", response_model=List[SessionResponse])
93
+ async def get_project_sessions(project_id: str, limit: int = 50):
94
+ """Get all sessions for a project."""
95
+ sessions = list_sessions(project_id, limit=limit)
96
+ return [
97
+ SessionResponse(
98
+ id=s.id,
99
+ jsonl_file_path=s.jsonl_file_path,
100
+ last_modified_at=s.last_modified_at.isoformat(),
101
+ message_count=s.message_count,
102
+ first_user_message=s.first_user_message,
103
+ )
104
+ for s in sessions
105
+ ]
106
+
107
+
108
+ @router.get("/sessions/{session_id}", response_model=SessionDetailResponse)
109
+ async def get_session_detail(session_id: str):
110
+ """Get full session detail with all conversations."""
111
+ try:
112
+ messages = load_session_history(session_id)
113
+ except FileNotFoundError:
114
+ raise HTTPException(status_code=404, detail="Session not found")
115
+
116
+ return SessionDetailResponse(
117
+ id=session_id,
118
+ jsonl_file_path=decode_session_id(session_id),
119
+ conversations=[
120
+ ConversationResponse(
121
+ type=m.type,
122
+ uuid=m.uuid,
123
+ timestamp=m.timestamp,
124
+ content=m.content,
125
+ is_sidechain=m.is_sidechain,
126
+ parent_uuid=m.parent_uuid,
127
+ tool_calls=[
128
+ ToolCall(
129
+ id=tc.get('id'),
130
+ name=tc.get('name'),
131
+ input=tc.get('input', {}),
132
+ )
133
+ for tc in m.tool_calls
134
+ ],
135
+ tool_results=[
136
+ ToolResult(
137
+ tool_use_id=tr.get('tool_use_id'),
138
+ content=tr.get('content'),
139
+ is_error=tr.get('is_error', False),
140
+ )
141
+ for tr in m.tool_results
142
+ ],
143
+ )
144
+ for m in messages
145
+ ],
146
+ )
147
+
148
+
149
+ # ============================================
150
+ # Codex History Endpoints
151
+ # ============================================
152
+
153
+ class CodexProjectResponse(BaseModel):
154
+ """Response model for a Codex project."""
155
+ id: str
156
+ name: str
157
+ path: str
158
+ last_modified_at: str
159
+ session_count: int
160
+
161
+
162
+ class CodexSessionResponse(BaseModel):
163
+ """Response model for a Codex session."""
164
+ id: str
165
+ session_uuid: str
166
+ jsonl_file_path: str
167
+ first_user_message: Optional[str]
168
+ message_count: int
169
+ last_modified_at: str
170
+
171
+
172
+ @router.get("/codex/projects", response_model=List[CodexProjectResponse])
173
+ async def get_codex_projects():
174
+ """List all Codex projects with history."""
175
+ projects = list_codex_projects()
176
+ return [
177
+ CodexProjectResponse(
178
+ id=p["id"],
179
+ name=p["name"],
180
+ path=p["path"],
181
+ last_modified_at=p["last_modified_at"],
182
+ session_count=p["session_count"],
183
+ )
184
+ for p in projects
185
+ ]
186
+
187
+
188
+ @router.get("/codex/projects/{project_id}/sessions", response_model=List[CodexSessionResponse])
189
+ async def get_codex_project_sessions(project_id: str):
190
+ """Get all sessions for a Codex project."""
191
+ sessions = list_codex_sessions(project_id)
192
+ return [
193
+ CodexSessionResponse(
194
+ id=s["id"],
195
+ session_uuid=s["session_uuid"],
196
+ jsonl_file_path=s["jsonl_file_path"],
197
+ first_user_message=s["first_user_message"],
198
+ message_count=s["message_count"],
199
+ last_modified_at=s["last_modified_at"],
200
+ )
201
+ for s in sessions
202
+ ]