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