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,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
|