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,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ClaudeCode History Reader
|
|
3
|
+
=========================
|
|
4
|
+
|
|
5
|
+
Reads and parses ClaudeCode conversation history from ~/.claude/projects/
|
|
6
|
+
Based on claude-code-viewer-1 implementation - reads .jsonl files directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, List, Optional
|
|
15
|
+
|
|
16
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
17
|
+
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode_project_id(path: str) -> str:
|
|
21
|
+
"""Encode a project path to a URL-safe base64 ID."""
|
|
22
|
+
return base64.urlsafe_b64encode(path.encode('utf-8')).decode('ascii')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def decode_project_id(project_id: str) -> str:
|
|
26
|
+
"""Decode a project ID back to a path."""
|
|
27
|
+
# Add padding if needed
|
|
28
|
+
padding = 4 - len(project_id) % 4
|
|
29
|
+
if padding != 4:
|
|
30
|
+
project_id += '=' * padding
|
|
31
|
+
return base64.urlsafe_b64decode(project_id.encode('ascii')).decode('utf-8')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encode_session_id(jsonl_path: str) -> str:
|
|
35
|
+
"""Encode a session jsonl path to a URL-safe base64 ID."""
|
|
36
|
+
return base64.urlsafe_b64encode(jsonl_path.encode('utf-8')).decode('ascii')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_session_id(session_id: str) -> str:
|
|
40
|
+
"""Decode a session ID back to a jsonl path."""
|
|
41
|
+
# Add padding if needed
|
|
42
|
+
padding = 4 - len(session_id) % 4
|
|
43
|
+
if padding != 4:
|
|
44
|
+
session_id += '=' * padding
|
|
45
|
+
return base64.urlsafe_b64decode(session_id.encode('ascii')).decode('utf-8')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ProjectInfo:
|
|
50
|
+
"""Information about a ClaudeCode project."""
|
|
51
|
+
id: str
|
|
52
|
+
name: str
|
|
53
|
+
path: str
|
|
54
|
+
last_modified_at: datetime
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SessionInfo:
|
|
59
|
+
"""Information about a ClaudeCode session."""
|
|
60
|
+
id: str
|
|
61
|
+
jsonl_file_path: str
|
|
62
|
+
last_modified_at: datetime
|
|
63
|
+
message_count: int = 0
|
|
64
|
+
first_user_message: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ConversationMessage:
|
|
69
|
+
"""A message from a ClaudeCode conversation."""
|
|
70
|
+
type: str # "user" | "assistant" | "system" | "summary" | etc.
|
|
71
|
+
uuid: str
|
|
72
|
+
timestamp: str
|
|
73
|
+
content: Any # Can be string or structured content
|
|
74
|
+
is_sidechain: bool = False
|
|
75
|
+
parent_uuid: Optional[str] = None
|
|
76
|
+
tool_calls: List[dict] = field(default_factory=list)
|
|
77
|
+
tool_results: List[dict] = field(default_factory=list)
|
|
78
|
+
raw_entry: dict = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_regular_session_file(filename: str) -> bool:
|
|
82
|
+
"""Check if file is a regular session file (not agent-*.jsonl)."""
|
|
83
|
+
if not filename.endswith('.jsonl'):
|
|
84
|
+
return False
|
|
85
|
+
if filename.startswith('agent-'):
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_cwd_from_jsonl(jsonl_path: Path) -> Optional[str]:
|
|
91
|
+
"""
|
|
92
|
+
Extract the cwd (current working directory) from a JSONL session file.
|
|
93
|
+
This gives us the actual project path.
|
|
94
|
+
Based on claude-code-viewer-1's ProjectMetaService implementation.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with open(jsonl_path, 'r', encoding='utf-8') as f:
|
|
98
|
+
for line in f:
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if not line:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(line)
|
|
104
|
+
# Look for cwd field in the conversation entry
|
|
105
|
+
if isinstance(data, dict) and 'cwd' in data:
|
|
106
|
+
cwd = data['cwd']
|
|
107
|
+
if cwd:
|
|
108
|
+
return cwd
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
except (IOError, OSError):
|
|
112
|
+
pass
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_project_path_from_sessions(dir_entry: Path) -> Optional[str]:
|
|
117
|
+
"""
|
|
118
|
+
Get the original project path by reading cwd from session JSONL files.
|
|
119
|
+
This is the approach used by claude-code-viewer-1.
|
|
120
|
+
"""
|
|
121
|
+
# Find all .jsonl files (excluding agent-*.jsonl)
|
|
122
|
+
jsonl_files = []
|
|
123
|
+
try:
|
|
124
|
+
for f in dir_entry.iterdir():
|
|
125
|
+
if f.is_file() and is_regular_session_file(f.name):
|
|
126
|
+
try:
|
|
127
|
+
stat = f.stat()
|
|
128
|
+
jsonl_files.append((f, stat.st_mtime))
|
|
129
|
+
except OSError:
|
|
130
|
+
continue
|
|
131
|
+
except (IOError, OSError):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Sort by modification time (oldest first, to find original cwd)
|
|
135
|
+
jsonl_files.sort(key=lambda x: x[1])
|
|
136
|
+
|
|
137
|
+
# Try each file until we find a cwd
|
|
138
|
+
for jsonl_path, _ in jsonl_files:
|
|
139
|
+
cwd = extract_cwd_from_jsonl(jsonl_path)
|
|
140
|
+
if cwd:
|
|
141
|
+
return cwd
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_original_path_from_dir(dir_entry: Path) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Get original project path from directory.
|
|
149
|
+
Priority:
|
|
150
|
+
1. sessions-index.json (if exists and has originalPath)
|
|
151
|
+
2. cwd from .jsonl session files (like claude-code-viewer-1)
|
|
152
|
+
3. Directory name as fallback
|
|
153
|
+
"""
|
|
154
|
+
# Try to read from sessions-index.json first
|
|
155
|
+
index_path = dir_entry / "sessions-index.json"
|
|
156
|
+
if index_path.exists():
|
|
157
|
+
try:
|
|
158
|
+
with open(index_path, 'r', encoding='utf-8') as f:
|
|
159
|
+
index_data = json.load(f)
|
|
160
|
+
original_path = index_data.get("originalPath", "")
|
|
161
|
+
if original_path:
|
|
162
|
+
return original_path
|
|
163
|
+
except (json.JSONDecodeError, IOError):
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
# Try to extract cwd from session files (like claude-code-viewer-1)
|
|
167
|
+
cwd = get_project_path_from_sessions(dir_entry)
|
|
168
|
+
if cwd:
|
|
169
|
+
return cwd
|
|
170
|
+
|
|
171
|
+
# Fallback: just use directory name (don't try to decode - it's lossy)
|
|
172
|
+
return dir_entry.name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_projects() -> List[ProjectInfo]:
|
|
176
|
+
"""List all ClaudeCode projects."""
|
|
177
|
+
if not PROJECTS_DIR.exists():
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
projects = []
|
|
181
|
+
|
|
182
|
+
for dir_entry in PROJECTS_DIR.iterdir():
|
|
183
|
+
if not dir_entry.is_dir() or dir_entry.name.startswith('.'):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
stat = dir_entry.stat()
|
|
188
|
+
last_modified = datetime.fromtimestamp(stat.st_mtime)
|
|
189
|
+
except OSError:
|
|
190
|
+
last_modified = datetime.now()
|
|
191
|
+
|
|
192
|
+
original_path = get_original_path_from_dir(dir_entry)
|
|
193
|
+
project_name = original_path.split('/')[-1] if '/' in original_path else original_path
|
|
194
|
+
|
|
195
|
+
projects.append(ProjectInfo(
|
|
196
|
+
id=encode_project_id(str(dir_entry)),
|
|
197
|
+
name=project_name,
|
|
198
|
+
path=original_path,
|
|
199
|
+
last_modified_at=last_modified,
|
|
200
|
+
))
|
|
201
|
+
|
|
202
|
+
# Sort by last modified (newest first)
|
|
203
|
+
return sorted(projects, key=lambda p: p.last_modified_at, reverse=True)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def list_sessions(project_id: str, limit: int = 50) -> List[SessionInfo]:
|
|
207
|
+
"""List all sessions for a project."""
|
|
208
|
+
project_path = Path(decode_project_id(project_id))
|
|
209
|
+
|
|
210
|
+
if not project_path.exists() or not project_path.is_dir():
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
sessions = []
|
|
214
|
+
|
|
215
|
+
for entry in project_path.iterdir():
|
|
216
|
+
if not is_regular_session_file(entry.name):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
stat = entry.stat()
|
|
221
|
+
last_modified = datetime.fromtimestamp(stat.st_mtime)
|
|
222
|
+
except OSError:
|
|
223
|
+
last_modified = datetime.now()
|
|
224
|
+
|
|
225
|
+
# Get first user message and message count by scanning the file
|
|
226
|
+
first_user_message = None
|
|
227
|
+
message_count = 0
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
with open(entry, 'r', encoding='utf-8') as f:
|
|
231
|
+
for line in f:
|
|
232
|
+
line = line.strip()
|
|
233
|
+
if not line:
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(line)
|
|
237
|
+
msg_type = data.get('type')
|
|
238
|
+
if msg_type in ('user', 'assistant'):
|
|
239
|
+
message_count += 1
|
|
240
|
+
if msg_type == 'user' and first_user_message is None:
|
|
241
|
+
message = data.get('message', {})
|
|
242
|
+
content = message.get('content', '')
|
|
243
|
+
if isinstance(content, str):
|
|
244
|
+
first_user_message = content[:300]
|
|
245
|
+
elif isinstance(content, list):
|
|
246
|
+
# Find first text content
|
|
247
|
+
for item in content:
|
|
248
|
+
if isinstance(item, str):
|
|
249
|
+
first_user_message = item[:300]
|
|
250
|
+
break
|
|
251
|
+
elif isinstance(item, dict) and item.get('type') == 'text':
|
|
252
|
+
first_user_message = item.get('text', '')[:300]
|
|
253
|
+
break
|
|
254
|
+
except json.JSONDecodeError:
|
|
255
|
+
continue
|
|
256
|
+
except IOError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
sessions.append(SessionInfo(
|
|
260
|
+
id=encode_session_id(str(entry)),
|
|
261
|
+
jsonl_file_path=str(entry),
|
|
262
|
+
last_modified_at=last_modified,
|
|
263
|
+
message_count=message_count,
|
|
264
|
+
first_user_message=first_user_message,
|
|
265
|
+
))
|
|
266
|
+
|
|
267
|
+
# Sort by last modified (newest first)
|
|
268
|
+
sessions = sorted(sessions, key=lambda s: s.last_modified_at, reverse=True)
|
|
269
|
+
|
|
270
|
+
return sessions[:limit]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_jsonl_entry(entry: dict) -> Optional[ConversationMessage]:
|
|
274
|
+
"""Parse a JSONL entry into a ConversationMessage."""
|
|
275
|
+
entry_type = entry.get('type')
|
|
276
|
+
if entry_type not in ('user', 'assistant', 'system', 'summary'):
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
uuid = entry.get('uuid', '')
|
|
280
|
+
timestamp = entry.get('timestamp', '')
|
|
281
|
+
is_sidechain = entry.get('isSidechain', False)
|
|
282
|
+
parent_uuid = entry.get('parentUuid')
|
|
283
|
+
|
|
284
|
+
# Extract content based on type
|
|
285
|
+
content: Any = None
|
|
286
|
+
tool_calls: List[dict] = []
|
|
287
|
+
tool_results: List[dict] = []
|
|
288
|
+
|
|
289
|
+
if entry_type == 'summary':
|
|
290
|
+
content = entry.get('summary', '')
|
|
291
|
+
elif entry_type == 'system':
|
|
292
|
+
content = entry.get('content', '')
|
|
293
|
+
else:
|
|
294
|
+
message = entry.get('message', {})
|
|
295
|
+
raw_content = message.get('content', '')
|
|
296
|
+
|
|
297
|
+
if isinstance(raw_content, str):
|
|
298
|
+
content = raw_content
|
|
299
|
+
elif isinstance(raw_content, list):
|
|
300
|
+
# Process structured content
|
|
301
|
+
text_parts = []
|
|
302
|
+
for item in raw_content:
|
|
303
|
+
if isinstance(item, str):
|
|
304
|
+
text_parts.append(item)
|
|
305
|
+
elif isinstance(item, dict):
|
|
306
|
+
item_type = item.get('type', '')
|
|
307
|
+
if item_type == 'text':
|
|
308
|
+
text_parts.append(item.get('text', ''))
|
|
309
|
+
elif item_type == 'thinking':
|
|
310
|
+
# Include thinking as a special marker
|
|
311
|
+
thinking_text = item.get('thinking', '')
|
|
312
|
+
text_parts.append(f'<thinking>{thinking_text}</thinking>')
|
|
313
|
+
elif item_type == 'tool_use':
|
|
314
|
+
tool_calls.append({
|
|
315
|
+
'id': item.get('id'),
|
|
316
|
+
'name': item.get('name'),
|
|
317
|
+
'input': item.get('input', {}),
|
|
318
|
+
})
|
|
319
|
+
elif item_type == 'tool_result':
|
|
320
|
+
tool_results.append({
|
|
321
|
+
'tool_use_id': item.get('tool_use_id'),
|
|
322
|
+
'content': item.get('content'),
|
|
323
|
+
'is_error': item.get('is_error', False),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
content = {
|
|
327
|
+
'text': '\n'.join(text_parts) if text_parts else '',
|
|
328
|
+
'raw': raw_content,
|
|
329
|
+
}
|
|
330
|
+
else:
|
|
331
|
+
content = str(raw_content)
|
|
332
|
+
|
|
333
|
+
return ConversationMessage(
|
|
334
|
+
type=entry_type,
|
|
335
|
+
uuid=uuid,
|
|
336
|
+
timestamp=timestamp,
|
|
337
|
+
content=content,
|
|
338
|
+
is_sidechain=is_sidechain,
|
|
339
|
+
parent_uuid=parent_uuid,
|
|
340
|
+
tool_calls=tool_calls,
|
|
341
|
+
tool_results=tool_results,
|
|
342
|
+
raw_entry=entry,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def load_session_history(session_id: str) -> List[ConversationMessage]:
|
|
347
|
+
"""Load full conversation history from a session JSONL file."""
|
|
348
|
+
session_path = Path(decode_session_id(session_id))
|
|
349
|
+
|
|
350
|
+
if not session_path.exists():
|
|
351
|
+
raise FileNotFoundError(f"Session not found: {session_id}")
|
|
352
|
+
|
|
353
|
+
messages = []
|
|
354
|
+
|
|
355
|
+
with open(session_path, 'r', encoding='utf-8') as f:
|
|
356
|
+
for line in f:
|
|
357
|
+
line = line.strip()
|
|
358
|
+
if not line:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
entry = json.loads(line)
|
|
363
|
+
except json.JSONDecodeError:
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
message = parse_jsonl_entry(entry)
|
|
367
|
+
if message:
|
|
368
|
+
messages.append(message)
|
|
369
|
+
|
|
370
|
+
return messages
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def format_context_for_injection(
|
|
374
|
+
messages: List[ConversationMessage],
|
|
375
|
+
max_chars: int = 100000
|
|
376
|
+
) -> str:
|
|
377
|
+
"""
|
|
378
|
+
Format conversation history for injection into Claude's context.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
messages: List of conversation messages
|
|
382
|
+
max_chars: Maximum characters to include
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Formatted string suitable for system prompt injection
|
|
386
|
+
"""
|
|
387
|
+
lines = []
|
|
388
|
+
current_chars = 0
|
|
389
|
+
|
|
390
|
+
for msg in messages:
|
|
391
|
+
if msg.is_sidechain:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
role_label = "Human" if msg.type == "user" else "Claude"
|
|
395
|
+
|
|
396
|
+
# Extract text content
|
|
397
|
+
if isinstance(msg.content, dict):
|
|
398
|
+
text = msg.content.get('text', '')
|
|
399
|
+
else:
|
|
400
|
+
text = str(msg.content)
|
|
401
|
+
|
|
402
|
+
# Cap individual messages
|
|
403
|
+
content = text[:3000] if len(text) > 3000 else text
|
|
404
|
+
line = f"**{role_label}**: {content}\n\n"
|
|
405
|
+
|
|
406
|
+
if current_chars + len(line) > max_chars:
|
|
407
|
+
lines.append("... [earlier context truncated] ...")
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
lines.append(line)
|
|
411
|
+
current_chars += len(line)
|
|
412
|
+
|
|
413
|
+
return "".join(lines)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def get_session_summary(session_id: str, max_messages: int = 10) -> str:
|
|
417
|
+
"""
|
|
418
|
+
Get a brief summary of a session for display.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
session_id: Session ID
|
|
422
|
+
max_messages: Maximum number of messages to include
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Formatted summary string
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
messages = load_session_history(session_id)
|
|
429
|
+
except FileNotFoundError:
|
|
430
|
+
return "Session not found"
|
|
431
|
+
|
|
432
|
+
if not messages:
|
|
433
|
+
return "Empty session"
|
|
434
|
+
|
|
435
|
+
lines = []
|
|
436
|
+
for msg in messages[:max_messages]:
|
|
437
|
+
if msg.is_sidechain:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
role = "Human" if msg.type == "user" else "Claude"
|
|
441
|
+
|
|
442
|
+
if isinstance(msg.content, dict):
|
|
443
|
+
text = msg.content.get('text', '')
|
|
444
|
+
else:
|
|
445
|
+
text = str(msg.content)
|
|
446
|
+
|
|
447
|
+
content = text[:200]
|
|
448
|
+
if len(text) > 200:
|
|
449
|
+
content += "..."
|
|
450
|
+
lines.append(f"[{role}]: {content}")
|
|
451
|
+
|
|
452
|
+
if len(messages) > max_messages:
|
|
453
|
+
lines.append(f"... and {len(messages) - max_messages} more messages")
|
|
454
|
+
|
|
455
|
+
return "\n".join(lines)
|