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,357 @@
1
+ """
2
+ Codex Participant Agent
3
+ =======================
4
+
5
+ Codex SDK を使用した Codex エージェント。
6
+ サブプロセスとして実行し、JSON Lines 形式で出力。
7
+
8
+ Required:
9
+ pip install codex-sdk-py
10
+
11
+ Usage:
12
+ python -m backend.services.codex_agent \
13
+ --participant-id 1 \
14
+ --participant-name "Codex A" \
15
+ --participant-role "Code Expert" \
16
+ --data-file /tmp/context.json \
17
+ --mode speak
18
+
19
+ Output (JSON Lines to stdout):
20
+ {"type": "text", "content": "..."}
21
+ {"type": "response_complete", "full_content": "..."}
22
+ """
23
+
24
+ import argparse
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ # Codex SDK
33
+ try:
34
+ from codex_sdk import Codex, SandboxMode, ApprovalMode
35
+ except ImportError:
36
+ print(json.dumps({
37
+ "type": "error",
38
+ "content": "Codex SDK is not installed. Please run: pip install codex-sdk-py"
39
+ }), flush=True)
40
+ sys.exit(1)
41
+
42
+ # Configure logging to stderr (stdout is reserved for JSON output)
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
46
+ stream=sys.stderr,
47
+ )
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ def emit_json(data: dict) -> None:
52
+ """Emit a JSON line to stdout."""
53
+ print(json.dumps(data, ensure_ascii=False), flush=True)
54
+
55
+
56
+ def build_prompt(
57
+ name: str,
58
+ role: str,
59
+ topic: str,
60
+ context: str,
61
+ conversation_history: str,
62
+ meeting_type_prompt: str,
63
+ language: str = "ja",
64
+ is_facilitator: bool = False,
65
+ mode: str = "speak",
66
+ preparation_notes: str = "",
67
+ ) -> str:
68
+ """Build the prompt for Codex participant."""
69
+ role_desc = role or "discussion participant"
70
+
71
+ language_instruction = (
72
+ "あなたは日本語で議論に参加します。全ての発言は日本語で行ってください。"
73
+ if language == "ja"
74
+ else "You participate in the discussion in English. All your responses should be in English."
75
+ )
76
+
77
+ if is_facilitator:
78
+ base_prompt = f"""You are {name}, the facilitator of this multi-Claude discussion room.
79
+
80
+ {language_instruction}
81
+
82
+ ## READ-ONLY MODE
83
+ This is a discussion-only environment. DO NOT modify any files.
84
+
85
+ ## Discussion Topic
86
+ {topic}
87
+
88
+ {meeting_type_prompt}
89
+
90
+ ## Your Task
91
+ You are generating the CLOSING message for this discussion.
92
+ Please summarize:
93
+ 1. The key discussion points
94
+ 2. Decisions made (if any)
95
+ 3. Next actions or open items
96
+
97
+ ## Response Format
98
+ - Start with [{name}]:
99
+ - Be concise but comprehensive
100
+ - Thank the participants at the end
101
+ """
102
+ elif mode == "prepare":
103
+ base_prompt = f"""You are {name}, a {role_desc} preparing for a multi-Claude discussion.
104
+
105
+ {language_instruction}
106
+
107
+ ## PREPARATION MODE
108
+ You are preparing to contribute to a discussion. Your task is to:
109
+ 1. Read relevant files to understand the codebase
110
+ 2. Search for information that will be useful for the discussion
111
+ 3. Take notes on key findings
112
+
113
+ **DO NOT** generate a discussion response yet. Instead, output a summary of your findings
114
+ that will help you when it's your turn to speak.
115
+
116
+ {meeting_type_prompt}
117
+
118
+ ## Discussion Topic
119
+ {topic}
120
+
121
+ ## Your Background Context
122
+ {context if context else "(No prior context provided)"}
123
+
124
+ ## Output Format
125
+ Summarize your findings in 2-3 paragraphs that will help you contribute to the discussion.
126
+ Focus on technical details, code patterns, and insights relevant to the topic.
127
+ """
128
+ else:
129
+ base_prompt = f"""You are {name}, a {role_desc} in a multi-Claude discussion room.
130
+
131
+ {language_instruction}
132
+
133
+ ## READ-ONLY MODE
134
+ This is a DISCUSSION-ONLY environment. DO NOT modify any files.
135
+
136
+ {meeting_type_prompt}
137
+
138
+ ## Discussion Topic
139
+ {topic}
140
+
141
+ ## Your Background Context
142
+ {context if context else "(No prior context provided)"}
143
+
144
+ ## Discussion Guidelines
145
+ 1. Reference other participants by name
146
+ 2. Be concise (2-4 paragraphs)
147
+ 3. Start with [{name}]:
148
+ 4. Focus on analysis, architecture discussions, code review feedback, and sharing knowledge
149
+ 5. Do NOT offer to implement anything - only discuss approaches and trade-offs
150
+ """
151
+
152
+ # Add conversation history and final instruction
153
+ if mode == "prepare":
154
+ return f"""{base_prompt}
155
+
156
+ ## Current Discussion (for context)
157
+ {conversation_history if conversation_history else "(Discussion not started yet)"}
158
+
159
+ Please analyze and prepare notes for your upcoming contribution to this discussion.
160
+ """
161
+ else:
162
+ prompt = f"""{base_prompt}
163
+
164
+ ## Current Discussion
165
+ {conversation_history}
166
+
167
+ """
168
+ if preparation_notes:
169
+ prompt += f"""## Your Preparation Notes
170
+ {preparation_notes}
171
+
172
+ """
173
+ prompt += f"""Please provide your response to continue the discussion. Remember to start with [{name}]:"""
174
+ return prompt
175
+
176
+
177
+ async def run_codex_agent(
178
+ participant_id: int,
179
+ participant_name: str,
180
+ participant_role: str,
181
+ room_topic: str,
182
+ context_text: str,
183
+ conversation_history: str,
184
+ cwd: Optional[str] = None,
185
+ mode: str = "speak",
186
+ preparation_notes: str = "",
187
+ meeting_type: Optional[str] = None,
188
+ custom_meeting_description: str = "",
189
+ language: str = "ja",
190
+ is_facilitator: bool = False,
191
+ ) -> None:
192
+ """
193
+ Run the Codex agent to generate one response.
194
+
195
+ Uses Codex SDK (codex-sdk-py package).
196
+ """
197
+ logger.info(f"Starting Codex agent: {participant_name} (mode={mode}, lang={language})")
198
+
199
+ # Build meeting type prompt
200
+ meeting_type_prompt = ""
201
+ if meeting_type:
202
+ # Import meeting_prompts - handle both module and standalone execution
203
+ try:
204
+ from .meeting_prompts import get_meeting_type_prompt
205
+ except ImportError:
206
+ sys.path.insert(0, str(Path(__file__).parent))
207
+ from meeting_prompts import get_meeting_type_prompt
208
+
209
+ meeting_type_prompt = get_meeting_type_prompt(meeting_type, custom_meeting_description)
210
+
211
+ prompt = build_prompt(
212
+ name=participant_name,
213
+ role=participant_role,
214
+ topic=room_topic,
215
+ context=context_text,
216
+ conversation_history=conversation_history,
217
+ meeting_type_prompt=meeting_type_prompt,
218
+ language=language,
219
+ is_facilitator=is_facilitator,
220
+ mode=mode,
221
+ preparation_notes=preparation_notes,
222
+ )
223
+
224
+ try:
225
+ # Create Codex client
226
+ codex = Codex()
227
+
228
+ # Start thread with read-only sandbox mode
229
+ thread_options = {
230
+ "sandbox_mode": SandboxMode.READ_ONLY,
231
+ "approval_policy": ApprovalMode.NEVER,
232
+ "skip_git_repo_check": True, # Always skip for discussion mode
233
+ }
234
+
235
+ # Set working directory if provided
236
+ if cwd:
237
+ thread_options["working_directory"] = cwd
238
+
239
+ thread = codex.start_thread(thread_options)
240
+
241
+ # Run with streaming to get intermediate events
242
+ streamed = await thread.run_streamed(prompt)
243
+
244
+ full_response = ""
245
+ async for event in streamed.events:
246
+ event_type = event.get("type")
247
+ # Emit debug info as JSON to stdout (so orchestrator can see it)
248
+ emit_json({"type": "debug", "event_type": event_type, "event_data": json.dumps(event, default=str)[:500]})
249
+
250
+ if event_type == "item.completed":
251
+ item = event.get("item", {})
252
+ item_type = item.get("type")
253
+
254
+ if item_type == "agent_message":
255
+ # agent_message has direct "text" field
256
+ text = item.get("text", "")
257
+ if text:
258
+ full_response += text
259
+ emit_json({"type": "text", "content": text})
260
+
261
+ elif item_type == "reasoning":
262
+ # reasoning items have "text" field too, but we skip them for now
263
+ pass
264
+
265
+ elif item_type == "command_execution":
266
+ # Tool use - show what command was executed
267
+ command = item.get("command", "")
268
+ emit_json({
269
+ "type": "tool_use",
270
+ "tool": "command",
271
+ "input": command[:200],
272
+ })
273
+
274
+ elif item_type == "file_change":
275
+ # File read/write
276
+ file_path = item.get("file_path", "")
277
+ action = item.get("action", "read")
278
+ emit_json({
279
+ "type": "tool_use",
280
+ "tool": f"file_{action}",
281
+ "input": file_path[:200],
282
+ })
283
+
284
+ elif event_type == "turn.completed":
285
+ # Turn completed - extract final response if not already captured
286
+ turn_response = event.get("final_response", "")
287
+ if turn_response and not full_response:
288
+ full_response = turn_response
289
+ emit_json({"type": "text", "content": turn_response})
290
+ logger.info(f"Turn completed, full_response length: {len(full_response)}")
291
+
292
+ elif event_type == "turn.failed":
293
+ error = event.get("error", "Unknown error")
294
+ logger.error(f"Turn failed: {error}")
295
+ emit_json({"type": "error", "content": str(error)})
296
+
297
+ # If still no response, try to get from streamed.turn
298
+ if not full_response and hasattr(streamed, 'turn') and streamed.turn:
299
+ full_response = getattr(streamed.turn, 'final_response', '') or ''
300
+ if full_response:
301
+ emit_json({"type": "text", "content": full_response})
302
+
303
+ # Emit completion
304
+ emit_json({
305
+ "type": "response_complete",
306
+ "full_content": full_response,
307
+ "mode": mode,
308
+ })
309
+
310
+ except Exception as e:
311
+ logger.error(f"Error in Codex agent: {e}")
312
+ emit_json({"type": "error", "content": str(e)})
313
+ sys.exit(1)
314
+
315
+
316
+ def main():
317
+ parser = argparse.ArgumentParser(description="Codex agent for discussions")
318
+ parser.add_argument("--participant-id", type=int, required=True, help="Participant database ID")
319
+ parser.add_argument("--participant-name", required=True, help="Participant display name")
320
+ parser.add_argument("--participant-role", default="", help="Participant role")
321
+ parser.add_argument("--data-file", required=True, help="JSON file with context data")
322
+ parser.add_argument("--cwd", default=None, help="Working directory for file operations")
323
+ parser.add_argument("--mode", choices=["speak", "prepare"], default="speak", help="Agent mode")
324
+ parser.add_argument("--meeting-type", default=None, help="Meeting type")
325
+ parser.add_argument("--language", default="ja", help="Language for discussion")
326
+ parser.add_argument("--is-facilitator", action="store_true", help="Whether this is a facilitator")
327
+
328
+ args = parser.parse_args()
329
+
330
+ # Load context data from file
331
+ try:
332
+ with open(args.data_file, "r", encoding="utf-8") as f:
333
+ data = json.load(f)
334
+ except Exception as e:
335
+ emit_json({"type": "error", "content": f"Failed to load data file: {e}"})
336
+ sys.exit(1)
337
+
338
+ # Run the agent
339
+ asyncio.run(run_codex_agent(
340
+ participant_id=args.participant_id,
341
+ participant_name=args.participant_name,
342
+ participant_role=args.participant_role,
343
+ room_topic=data.get("room_topic", ""),
344
+ context_text=data.get("context_text", ""),
345
+ conversation_history=data.get("conversation_history", ""),
346
+ cwd=args.cwd,
347
+ mode=args.mode,
348
+ preparation_notes=data.get("preparation_notes", ""),
349
+ meeting_type=args.meeting_type or data.get("meeting_type"),
350
+ custom_meeting_description=data.get("custom_meeting_description", ""),
351
+ language=args.language or data.get("language", "ja"),
352
+ is_facilitator=args.is_facilitator or data.get("is_facilitator", False),
353
+ ))
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
@@ -0,0 +1,287 @@
1
+ """
2
+ Codex History Reader
3
+ ====================
4
+
5
+ Read Codex session history from ~/.codex/sessions/
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Codex session storage paths
18
+ CODEX_SESSIONS_ROOT = Path.home() / ".codex" / "sessions"
19
+ CODEX_HISTORY_FILE = Path.home() / ".codex" / "history.jsonl"
20
+
21
+
22
+ def list_codex_projects() -> list[dict]:
23
+ """
24
+ List all Codex projects (workspaces).
25
+
26
+ Returns a list of projects, each with:
27
+ - id: encoded workspace path
28
+ - name: workspace directory name
29
+ - path: full workspace path
30
+ - last_modified_at: ISO timestamp
31
+ - session_count: number of sessions
32
+ """
33
+ if not CODEX_SESSIONS_ROOT.exists():
34
+ logger.warning(f"Codex sessions directory not found: {CODEX_SESSIONS_ROOT}")
35
+ return []
36
+
37
+ projects: dict[str, dict] = {}
38
+
39
+ # Recursively find all .jsonl session files
40
+ for jsonl_file in CODEX_SESSIONS_ROOT.rglob("*.jsonl"):
41
+ try:
42
+ session_meta = _read_session_header(jsonl_file)
43
+ if not session_meta:
44
+ continue
45
+
46
+ workspace_path = session_meta.get("cwd", "")
47
+ if not workspace_path:
48
+ continue
49
+
50
+ # Get file modification time
51
+ file_mtime = datetime.fromtimestamp(jsonl_file.stat().st_mtime)
52
+
53
+ if workspace_path not in projects:
54
+ projects[workspace_path] = {
55
+ "id": _encode_path(workspace_path),
56
+ "name": Path(workspace_path).name,
57
+ "path": workspace_path,
58
+ "last_modified_at": file_mtime,
59
+ "session_count": 0,
60
+ }
61
+
62
+ projects[workspace_path]["session_count"] += 1
63
+ if file_mtime > projects[workspace_path]["last_modified_at"]:
64
+ projects[workspace_path]["last_modified_at"] = file_mtime
65
+
66
+ except Exception as e:
67
+ logger.warning(f"Error reading Codex session file {jsonl_file}: {e}")
68
+
69
+ # Convert to list and format timestamps
70
+ result = []
71
+ for project in projects.values():
72
+ project["last_modified_at"] = project["last_modified_at"].isoformat()
73
+ result.append(project)
74
+
75
+ # Sort by last modified (most recent first)
76
+ result.sort(key=lambda p: p["last_modified_at"], reverse=True)
77
+ return result
78
+
79
+
80
+ def list_codex_sessions(project_id: str) -> list[dict]:
81
+ """
82
+ List all sessions for a Codex project.
83
+
84
+ Args:
85
+ project_id: Encoded workspace path
86
+
87
+ Returns a list of sessions, each with:
88
+ - id: session file path (encoded)
89
+ - session_uuid: unique session UUID
90
+ - first_user_message: first user message in the session
91
+ - message_count: total number of entries
92
+ - last_modified_at: ISO timestamp
93
+ """
94
+ workspace_path = _decode_path(project_id)
95
+ if not workspace_path:
96
+ return []
97
+
98
+ if not CODEX_SESSIONS_ROOT.exists():
99
+ return []
100
+
101
+ sessions = []
102
+
103
+ for jsonl_file in CODEX_SESSIONS_ROOT.rglob("*.jsonl"):
104
+ try:
105
+ session_meta = _read_session_header(jsonl_file)
106
+ if not session_meta:
107
+ continue
108
+
109
+ if session_meta.get("cwd") != workspace_path:
110
+ continue
111
+
112
+ # Count messages and get first user message
113
+ message_count = 0
114
+ first_user_message = None
115
+
116
+ with open(jsonl_file, "r", encoding="utf-8") as f:
117
+ for line in f:
118
+ line = line.strip()
119
+ if not line:
120
+ continue
121
+ try:
122
+ entry = json.loads(line)
123
+ message_count += 1
124
+
125
+ # Look for first user message
126
+ if first_user_message is None:
127
+ if entry.get("type") == "response_item":
128
+ payload = entry.get("payload", {})
129
+ if payload.get("role") == "user":
130
+ content = payload.get("content", "")
131
+ if isinstance(content, str):
132
+ first_user_message = content[:200]
133
+ elif isinstance(content, list):
134
+ for c in content:
135
+ if isinstance(c, dict) and c.get("type") == "input_text":
136
+ first_user_message = c.get("text", "")[:200]
137
+ break
138
+ elif entry.get("type") == "event_msg":
139
+ payload = entry.get("payload", {})
140
+ if payload.get("type") == "user_message":
141
+ first_user_message = payload.get("text", "")[:200]
142
+ except json.JSONDecodeError:
143
+ continue
144
+
145
+ sessions.append({
146
+ "id": _encode_path(str(jsonl_file)),
147
+ "session_uuid": session_meta.get("id", ""),
148
+ "jsonl_file_path": str(jsonl_file),
149
+ "first_user_message": first_user_message,
150
+ "message_count": message_count,
151
+ "last_modified_at": datetime.fromtimestamp(jsonl_file.stat().st_mtime).isoformat(),
152
+ })
153
+
154
+ except Exception as e:
155
+ logger.warning(f"Error reading Codex session file {jsonl_file}: {e}")
156
+
157
+ # Sort by last modified (most recent first)
158
+ sessions.sort(key=lambda s: s["last_modified_at"], reverse=True)
159
+ return sessions
160
+
161
+
162
+ def get_codex_session_context(session_id: str, max_chars: int = 50000) -> str:
163
+ """
164
+ Get the conversation context from a Codex session.
165
+
166
+ Args:
167
+ session_id: Encoded session file path
168
+ max_chars: Maximum characters to return
169
+
170
+ Returns:
171
+ Formatted conversation history string
172
+ """
173
+ file_path = _decode_path(session_id)
174
+ if not file_path or not Path(file_path).exists():
175
+ return ""
176
+
177
+ try:
178
+ lines = []
179
+ with open(file_path, "r", encoding="utf-8") as f:
180
+ for line_str in f:
181
+ line_str = line_str.strip()
182
+ if not line_str:
183
+ continue
184
+ try:
185
+ entry = json.loads(line_str)
186
+ formatted = _format_codex_entry(entry)
187
+ if formatted:
188
+ lines.append(formatted)
189
+ except json.JSONDecodeError:
190
+ continue
191
+
192
+ context = "\n".join(lines)
193
+ if len(context) > max_chars:
194
+ context = context[-max_chars:]
195
+ # Try to start at a message boundary
196
+ newline_idx = context.find("\n[")
197
+ if newline_idx > 0:
198
+ context = context[newline_idx + 1:]
199
+
200
+ return context
201
+
202
+ except Exception as e:
203
+ logger.error(f"Error reading Codex session context: {e}")
204
+ return ""
205
+
206
+
207
+ def _read_session_header(jsonl_file: Path) -> Optional[dict]:
208
+ """Read the session metadata from the first line of a Codex session file."""
209
+ try:
210
+ with open(jsonl_file, "r", encoding="utf-8") as f:
211
+ first_line = f.readline().strip()
212
+ if not first_line:
213
+ return None
214
+
215
+ entry = json.loads(first_line)
216
+ if entry.get("type") == "session_meta":
217
+ return entry.get("payload", {})
218
+
219
+ # Some files might have the metadata in a different format
220
+ if "cwd" in entry:
221
+ return entry
222
+
223
+ return None
224
+ except Exception:
225
+ return None
226
+
227
+
228
+ def _format_codex_entry(entry: dict) -> Optional[str]:
229
+ """Format a Codex session entry for context injection."""
230
+ entry_type = entry.get("type")
231
+
232
+ if entry_type == "response_item":
233
+ payload = entry.get("payload", {})
234
+ role = payload.get("role")
235
+ content = payload.get("content", "")
236
+
237
+ if role == "user":
238
+ if isinstance(content, str):
239
+ return f"[User]: {content}"
240
+ elif isinstance(content, list):
241
+ texts = []
242
+ for c in content:
243
+ if isinstance(c, dict) and c.get("type") == "input_text":
244
+ texts.append(c.get("text", ""))
245
+ if texts:
246
+ return f"[User]: {' '.join(texts)}"
247
+
248
+ elif role == "assistant":
249
+ if isinstance(content, str):
250
+ return f"[Assistant]: {content}"
251
+ elif isinstance(content, list):
252
+ texts = []
253
+ for c in content:
254
+ if isinstance(c, dict) and c.get("type") == "output_text":
255
+ texts.append(c.get("text", ""))
256
+ if texts:
257
+ return f"[Assistant]: {' '.join(texts)}"
258
+
259
+ elif payload.get("type") == "function_call":
260
+ name = payload.get("name", "unknown")
261
+ return f"[Tool Call]: {name}"
262
+
263
+ elif entry_type == "event_msg":
264
+ payload = entry.get("payload", {})
265
+ msg_type = payload.get("type")
266
+
267
+ if msg_type == "user_message":
268
+ return f"[User]: {payload.get('text', '')}"
269
+ elif msg_type == "agent_message":
270
+ return f"[Assistant]: {payload.get('text', '')}"
271
+
272
+ return None
273
+
274
+
275
+ def _encode_path(path: str) -> str:
276
+ """Encode a path to a URL-safe ID."""
277
+ import base64
278
+ return base64.urlsafe_b64encode(path.encode()).decode()
279
+
280
+
281
+ def _decode_path(encoded: str) -> Optional[str]:
282
+ """Decode a path ID back to the original path."""
283
+ try:
284
+ import base64
285
+ return base64.urlsafe_b64decode(encoded.encode()).decode()
286
+ except Exception:
287
+ return None