repr-cli 0.2.16__py3-none-any.whl → 0.2.18__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.
Files changed (49) hide show
  1. repr/__init__.py +1 -1
  2. repr/api.py +363 -62
  3. repr/auth.py +47 -38
  4. repr/change_synthesis.py +478 -0
  5. repr/cli.py +4306 -364
  6. repr/config.py +119 -11
  7. repr/configure.py +889 -0
  8. repr/cron.py +419 -0
  9. repr/dashboard/__init__.py +9 -0
  10. repr/dashboard/build.py +126 -0
  11. repr/dashboard/dist/assets/index-B-aCjaCw.js +384 -0
  12. repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
  13. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  14. repr/dashboard/dist/assets/index-C7Gzxc4f.js +384 -0
  15. repr/dashboard/dist/assets/index-CQdMXo6g.js +391 -0
  16. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  17. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  18. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  19. repr/dashboard/dist/assets/index-Cs8ofFGd.js +384 -0
  20. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  21. repr/dashboard/dist/assets/index-DwN0SeMc.css +1 -0
  22. repr/dashboard/dist/assets/index-YFch_e0S.js +384 -0
  23. repr/dashboard/dist/favicon.svg +4 -0
  24. repr/dashboard/dist/index.html +14 -0
  25. repr/dashboard/manager.py +234 -0
  26. repr/dashboard/server.py +1489 -0
  27. repr/db.py +980 -0
  28. repr/hooks.py +3 -2
  29. repr/loaders/__init__.py +22 -0
  30. repr/loaders/base.py +156 -0
  31. repr/loaders/claude_code.py +287 -0
  32. repr/loaders/clawdbot.py +313 -0
  33. repr/loaders/gemini_antigravity.py +381 -0
  34. repr/mcp_server.py +1196 -0
  35. repr/models.py +503 -0
  36. repr/openai_analysis.py +25 -0
  37. repr/session_extractor.py +481 -0
  38. repr/storage.py +328 -0
  39. repr/story_synthesis.py +1296 -0
  40. repr/templates.py +68 -4
  41. repr/timeline.py +710 -0
  42. repr/tools.py +17 -8
  43. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/METADATA +48 -10
  44. repr_cli-0.2.18.dist-info/RECORD +58 -0
  45. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/WHEEL +1 -1
  46. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/entry_points.txt +1 -0
  47. repr_cli-0.2.16.dist-info/RECORD +0 -26
  48. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/licenses/LICENSE +0 -0
  49. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,481 @@
1
+ """
2
+ LLM-based context extraction from AI sessions.
3
+
4
+ Takes session transcripts and extracts structured context:
5
+ - Problem: What was the user trying to solve?
6
+ - Approach: What strategy/pattern was used?
7
+ - Decisions: Key decisions made
8
+ - Outcome: Did it work?
9
+ - Lessons: Gotchas and learnings
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from .models import (
20
+ ContentBlockType,
21
+ MessageRole,
22
+ Session,
23
+ SessionContext,
24
+ SessionMessage,
25
+ )
26
+
27
+
28
+ # =============================================================================
29
+ # Extraction Schema
30
+ # =============================================================================
31
+
32
+ class ExtractedContext(BaseModel):
33
+ """Schema for LLM extraction output."""
34
+ problem: str = Field(description="What was the user trying to solve? One paragraph max.")
35
+ approach: str = Field(description="What strategy/pattern was used to solve it? Technical details.")
36
+ decisions: list[str] = Field(
37
+ default_factory=list,
38
+ description="Key decisions made (3-5 items). Format: 'Chose X over Y because Z'"
39
+ )
40
+ files_modified: list[str] = Field(
41
+ default_factory=list,
42
+ description="Files that were created or modified"
43
+ )
44
+ tools_used: list[str] = Field(
45
+ default_factory=list,
46
+ description="Main tools/commands used"
47
+ )
48
+ outcome: str = Field(description="Did it work? What was the result? One sentence.")
49
+ lessons: list[str] = Field(
50
+ default_factory=list,
51
+ description="Gotchas, learnings, things to remember (1-3 items)"
52
+ )
53
+
54
+
55
+ # =============================================================================
56
+ # Prompt Templates
57
+ # =============================================================================
58
+
59
+ EXTRACTION_SYSTEM_PROMPT = """You are a technical context extractor. Your job is to read AI coding assistant sessions and extract structured context that will help future AI agents understand this developer's work.
60
+
61
+ Extract the ESSENTIAL context that answers:
62
+ 1. WHAT problem was being solved (the motivation, not just the task)
63
+ 2. HOW it was approached (specific patterns, architectures, strategies)
64
+ 3. WHAT decisions were made and WHY (alternatives considered, tradeoffs made)
65
+ 4. WHAT worked, what didn't, what to watch out for
66
+
67
+ Write for a technical audience. Be SPECIFIC:
68
+ - Name exact technologies, libraries, patterns used
69
+ - Quote actual decisions from the conversation ("chose X because...")
70
+ - Include gotchas and learnings that would help someone working on similar code
71
+
72
+ DO NOT:
73
+ - Use generic descriptions ("improved the system", "fixed the issue")
74
+ - Include filler or marketing language
75
+ - Describe the extraction process itself
76
+ - Make up information not present in the session
77
+ - Leave fields empty - extract something meaningful or say "Not discussed"
78
+
79
+ Output valid JSON matching the schema provided."""
80
+
81
+ EXTRACTION_USER_PROMPT = """Extract structured context from this AI coding session.
82
+
83
+ <session>
84
+ {session_transcript}
85
+ </session>
86
+
87
+ Extract these fields (be specific, not generic):
88
+
89
+ - problem: What was the user trying to solve? What was broken/slow/missing? (1 paragraph)
90
+ - approach: What technical strategy/pattern was used? How does the solution work? (specific details)
91
+ - decisions: Key decisions made - each formatted as "Chose X over Y because Z" (3-5 items)
92
+ - files_modified: Files that were created or significantly modified
93
+ - tools_used: Main tools, commands, or APIs used
94
+ - outcome: Did it work? What was the observable result? (1-2 sentences)
95
+ - lessons: Gotchas discovered, things to remember, warnings for future work (1-3 items)
96
+
97
+ If a field wasn't discussed in the session, write "Not discussed" rather than guessing.
98
+
99
+ Respond with valid JSON only."""
100
+
101
+
102
+ # =============================================================================
103
+ # Session Formatter
104
+ # =============================================================================
105
+
106
+ def format_session_for_extraction(
107
+ session: Session,
108
+ max_messages: int = 50,
109
+ max_chars: int = 30000,
110
+ ) -> str:
111
+ """
112
+ Format a session transcript for LLM extraction.
113
+
114
+ Includes:
115
+ - User messages (full text)
116
+ - Assistant text responses (summarized if long)
117
+ - Tool calls (name + key inputs)
118
+
119
+ Args:
120
+ session: Session to format
121
+ max_messages: Maximum number of messages to include
122
+ max_chars: Maximum total characters
123
+
124
+ Returns:
125
+ Formatted transcript string
126
+ """
127
+ lines = []
128
+ total_chars = 0
129
+
130
+ # Add session metadata
131
+ lines.append(f"Session ID: {session.id}")
132
+ lines.append(f"Started: {session.started_at.isoformat()}")
133
+ if session.cwd:
134
+ lines.append(f"Working Directory: {session.cwd}")
135
+ if session.git_branch:
136
+ lines.append(f"Git Branch: {session.git_branch}")
137
+ lines.append("")
138
+ lines.append("=== CONVERSATION ===")
139
+ lines.append("")
140
+
141
+ # Process messages
142
+ messages_included = 0
143
+ for msg in session.messages:
144
+ if messages_included >= max_messages:
145
+ lines.append(f"... ({len(session.messages) - messages_included} more messages)")
146
+ break
147
+
148
+ role_label = msg.role.value.upper()
149
+ timestamp = msg.timestamp.strftime("%H:%M") if isinstance(msg.timestamp, datetime) else ""
150
+
151
+ # Format content
152
+ content_parts = []
153
+ for block in msg.content:
154
+ if block.type == ContentBlockType.TEXT and block.text:
155
+ # Truncate long text
156
+ text = block.text.strip()
157
+ if len(text) > 2000:
158
+ text = text[:1500] + "\n... (truncated) ...\n" + text[-300:]
159
+ content_parts.append(text)
160
+
161
+ elif block.type == ContentBlockType.TOOL_CALL:
162
+ tool_str = f"[TOOL: {block.name}]"
163
+ if block.input:
164
+ # Extract key inputs
165
+ key_inputs = []
166
+ for key in ["path", "command", "query", "url", "file_path"]:
167
+ if key in block.input:
168
+ key_inputs.append(f"{key}={block.input[key]}")
169
+ if key_inputs:
170
+ tool_str += f" {', '.join(key_inputs[:3])}"
171
+ content_parts.append(tool_str)
172
+
173
+ elif block.type == ContentBlockType.TOOL_RESULT and block.text:
174
+ # Very brief tool results
175
+ result = block.text.strip()
176
+ if len(result) > 500:
177
+ result = result[:200] + "... (truncated)"
178
+ content_parts.append(f"[RESULT]: {result}")
179
+
180
+ if content_parts:
181
+ content = "\n".join(content_parts)
182
+ message_text = f"[{role_label}] {timestamp}\n{content}\n"
183
+
184
+ # Check character limit
185
+ if total_chars + len(message_text) > max_chars:
186
+ lines.append(f"... (truncated at {max_chars} chars)")
187
+ break
188
+
189
+ lines.append(message_text)
190
+ total_chars += len(message_text)
191
+ messages_included += 1
192
+
193
+ return "\n".join(lines)
194
+
195
+
196
+ # =============================================================================
197
+ # Extractor
198
+ # =============================================================================
199
+
200
+ class SessionExtractor:
201
+ """Extract structured context from sessions using LLM."""
202
+
203
+ def __init__(
204
+ self,
205
+ api_key: str | None = None,
206
+ base_url: str | None = None,
207
+ model: str = "openai/gpt-4.1-mini",
208
+ ):
209
+ """
210
+ Initialize extractor.
211
+
212
+ Args:
213
+ api_key: API key for LLM provider
214
+ base_url: Base URL for API (for local LLMs)
215
+ model: Model to use for extraction
216
+ """
217
+ self.api_key = api_key
218
+ self.base_url = base_url
219
+ self.model = model
220
+ self._client = None
221
+
222
+ async def close(self):
223
+ """Close the underlying OpenAI client (no-op for sync client)."""
224
+ # Sync client doesn't need explicit closing
225
+ self._client = None
226
+
227
+ async def __aenter__(self):
228
+ return self
229
+
230
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
231
+ await self.close()
232
+
233
+ def _get_client(self):
234
+ """Get or create OpenAI client.
235
+
236
+ Uses sync client to avoid event loop cleanup issues when called
237
+ from sync contexts via asyncio.run().
238
+
239
+ Tries multiple sources for API key in order:
240
+ 1. Explicit api_key passed to constructor
241
+ 2. BYOK OpenAI config from repr
242
+ 3. Local LLM config from repr
243
+ 4. OPENAI_API_KEY environment variable
244
+ 5. LiteLLM config from repr (cloud mode)
245
+ """
246
+ if self._client is None:
247
+ import os
248
+ from openai import OpenAI
249
+
250
+ api_key = self.api_key
251
+ base_url = self.base_url
252
+
253
+ if not api_key:
254
+ try:
255
+ # Try BYOK OpenAI config first
256
+ from .config import get_byok_config, get_llm_config
257
+
258
+ byok = get_byok_config("openai")
259
+ if byok and byok.get("api_key"):
260
+ api_key = byok["api_key"]
261
+ base_url = base_url or byok.get("base_url")
262
+
263
+ # Try local LLM config
264
+ if not api_key:
265
+ llm_config = get_llm_config()
266
+ if llm_config.get("local_api_key"):
267
+ api_key = llm_config["local_api_key"]
268
+ base_url = base_url or llm_config.get("local_api_url")
269
+ except Exception:
270
+ pass
271
+
272
+ # Try environment variable
273
+ if not api_key:
274
+ api_key = os.getenv("OPENAI_API_KEY")
275
+
276
+ # Try LiteLLM config (cloud mode)
277
+ if not api_key:
278
+ try:
279
+ from .config import get_litellm_config
280
+ litellm_url, litellm_key = get_litellm_config()
281
+ if litellm_key:
282
+ api_key = litellm_key
283
+ base_url = base_url or litellm_url
284
+ except Exception:
285
+ pass
286
+
287
+ if not api_key:
288
+ raise ValueError(
289
+ "No API key found. Configure one via:\n"
290
+ " - repr llm byok openai <key> (recommended)\n"
291
+ " - OPENAI_API_KEY environment variable\n"
292
+ " - repr login (for cloud mode)"
293
+ )
294
+
295
+ self._client = OpenAI(
296
+ api_key=api_key,
297
+ base_url=base_url,
298
+ )
299
+ return self._client
300
+
301
+ async def extract_context(
302
+ self,
303
+ session: Session,
304
+ linked_commits: list[str] | None = None,
305
+ ) -> SessionContext:
306
+ """
307
+ Extract context from a session.
308
+
309
+ Args:
310
+ session: Session to extract from
311
+ linked_commits: Optional list of linked commit SHAs
312
+
313
+ Returns:
314
+ Extracted SessionContext
315
+ """
316
+ # Format session for LLM
317
+ transcript = format_session_for_extraction(session)
318
+
319
+ # Call LLM (using sync client to avoid event loop cleanup issues)
320
+ client = self._get_client()
321
+
322
+ try:
323
+ response = client.chat.completions.create(
324
+ model=self.model.split("/")[-1] if "/" in self.model else self.model,
325
+ messages=[
326
+ {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
327
+ {"role": "user", "content": EXTRACTION_USER_PROMPT.format(
328
+ session_transcript=transcript
329
+ )},
330
+ ],
331
+ response_format={"type": "json_object"},
332
+ temperature=0.3,
333
+ )
334
+
335
+ # Parse response
336
+ content = response.choices[0].message.content
337
+ extracted = ExtractedContext.model_validate_json(content)
338
+
339
+ except Exception as e:
340
+ # Fallback: create context from session metadata + first user message
341
+ first_user_msg = ""
342
+ for msg in session.messages:
343
+ if msg.role == MessageRole.USER and msg.text_content:
344
+ first_user_msg = msg.text_content[:500]
345
+ break
346
+
347
+ # Try to infer problem from first message
348
+ problem = first_user_msg if first_user_msg else f"AI coding session in {session.cwd or 'project directory'}"
349
+
350
+ # Determine if this is an API error vs other error
351
+ error_str = str(e)
352
+ is_api_error = any(x in error_str.lower() for x in ["401", "403", "api key", "unauthorized", "authentication"])
353
+
354
+ if is_api_error:
355
+ lesson = "Extraction skipped - configure API key with 'repr llm byok openai <key>'"
356
+ else:
357
+ lesson = f"Extraction failed: {error_str[:80]}"
358
+
359
+ extracted = ExtractedContext(
360
+ problem=problem[:500],
361
+ approach="Not extracted - see lessons",
362
+ decisions=[],
363
+ files_modified=session.files_touched[:10],
364
+ tools_used=session.tools_used[:10],
365
+ outcome=f"Session: {session.message_count} messages, {session.duration_seconds or 0:.0f}s",
366
+ lessons=[lesson],
367
+ )
368
+
369
+ # Build SessionContext
370
+ return SessionContext(
371
+ session_id=session.id,
372
+ timestamp=session.started_at,
373
+ problem=extracted.problem,
374
+ approach=extracted.approach,
375
+ decisions=extracted.decisions,
376
+ files_modified=extracted.files_modified or session.files_touched,
377
+ tools_used=extracted.tools_used or session.tools_used,
378
+ outcome=extracted.outcome,
379
+ lessons=extracted.lessons,
380
+ linked_commits=linked_commits or [],
381
+ )
382
+
383
+ async def extract_batch(
384
+ self,
385
+ sessions: list[Session],
386
+ concurrency: int = 3,
387
+ ) -> list[SessionContext]:
388
+ """
389
+ Extract context from multiple sessions with concurrency control.
390
+
391
+ Args:
392
+ sessions: Sessions to extract from
393
+ concurrency: Max concurrent extractions
394
+
395
+ Returns:
396
+ List of extracted SessionContexts
397
+ """
398
+ semaphore = asyncio.Semaphore(concurrency)
399
+
400
+ async def extract_with_semaphore(session: Session) -> SessionContext:
401
+ async with semaphore:
402
+ return await self.extract_context(session)
403
+
404
+ tasks = [extract_with_semaphore(s) for s in sessions]
405
+ return await asyncio.gather(*tasks)
406
+
407
+
408
+ # =============================================================================
409
+ # Convenience Functions
410
+ # =============================================================================
411
+
412
+ async def extract_session_context(
413
+ session: Session,
414
+ api_key: str | None = None,
415
+ model: str = "openai/gpt-4.1-mini",
416
+ ) -> SessionContext:
417
+ """
418
+ Convenience function to extract context from a single session.
419
+
420
+ Args:
421
+ session: Session to extract from
422
+ api_key: Optional API key
423
+ model: Model to use
424
+
425
+ Returns:
426
+ Extracted SessionContext
427
+ """
428
+ extractor = SessionExtractor(api_key=api_key, model=model)
429
+ return await extractor.extract_context(session)
430
+
431
+
432
+ def extract_session_context_sync(
433
+ session: Session,
434
+ api_key: str | None = None,
435
+ model: str = "openai/gpt-4.1-mini",
436
+ ) -> SessionContext:
437
+ """
438
+ Synchronous wrapper for extract_session_context.
439
+ """
440
+ return asyncio.run(extract_session_context(session, api_key, model))
441
+
442
+
443
+ # =============================================================================
444
+ # CLI Support
445
+ # =============================================================================
446
+
447
+ def summarize_session(session: Session) -> dict[str, Any]:
448
+ """
449
+ Create a quick summary of a session without LLM extraction.
450
+
451
+ Useful for listing/previewing sessions before full extraction.
452
+
453
+ Args:
454
+ session: Session to summarize
455
+
456
+ Returns:
457
+ Summary dict
458
+ """
459
+ # Get first user message as summary
460
+ first_user_msg = ""
461
+ for msg in session.messages:
462
+ if msg.role == MessageRole.USER:
463
+ first_user_msg = msg.text_content[:200]
464
+ if len(msg.text_content) > 200:
465
+ first_user_msg += "..."
466
+ break
467
+
468
+ return {
469
+ "id": session.id,
470
+ "started_at": session.started_at.isoformat(),
471
+ "ended_at": session.ended_at.isoformat() if session.ended_at else None,
472
+ "duration_seconds": session.duration_seconds,
473
+ "channel": session.channel,
474
+ "cwd": session.cwd,
475
+ "model": session.model,
476
+ "message_count": session.message_count,
477
+ "user_messages": session.user_message_count,
478
+ "tools_used": session.tools_used[:5],
479
+ "files_touched": session.files_touched[:5],
480
+ "first_message": first_user_msg,
481
+ }