repr-cli 0.2.16__py3-none-any.whl → 0.2.17__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 (43) 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 +4099 -280
  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-BYFVbEev.css +1 -0
  12. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  13. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  14. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  15. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  16. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  17. repr/dashboard/dist/favicon.svg +4 -0
  18. repr/dashboard/dist/index.html +14 -0
  19. repr/dashboard/manager.py +234 -0
  20. repr/dashboard/server.py +1298 -0
  21. repr/db.py +980 -0
  22. repr/hooks.py +3 -2
  23. repr/loaders/__init__.py +22 -0
  24. repr/loaders/base.py +156 -0
  25. repr/loaders/claude_code.py +287 -0
  26. repr/loaders/clawdbot.py +313 -0
  27. repr/loaders/gemini_antigravity.py +381 -0
  28. repr/mcp_server.py +1196 -0
  29. repr/models.py +503 -0
  30. repr/openai_analysis.py +25 -0
  31. repr/session_extractor.py +481 -0
  32. repr/storage.py +328 -0
  33. repr/story_synthesis.py +1296 -0
  34. repr/templates.py +68 -4
  35. repr/timeline.py +710 -0
  36. repr/tools.py +17 -8
  37. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
  38. repr_cli-0.2.17.dist-info/RECORD +52 -0
  39. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
  40. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
  41. repr_cli-0.2.16.dist-info/RECORD +0 -26
  42. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
  43. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,478 @@
1
+ """
2
+ Change synthesis - understand file changes across git states.
3
+
4
+ Detects and synthesizes changes across three git states:
5
+ 1. Tracked but not staged (working directory)
6
+ 2. Staged but not committed (index)
7
+ 3. Committed but not pushed (local commits ahead of remote)
8
+
9
+ Uses the tripartite codex format for synthesis:
10
+ - hook: Engagement hook (<60 chars)
11
+ - what: Observable change (1 sentence)
12
+ - value: Why it matters (1 sentence)
13
+ - problem: What was broken/missing (1 sentence)
14
+ - insight: Transferable lesson (1 sentence)
15
+ - show: Visual element (code/before-after)
16
+ """
17
+
18
+ import subprocess
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from git import Repo, InvalidGitRepositoryError
26
+ from pydantic import BaseModel, Field
27
+
28
+
29
+ class ChangeState(str, Enum):
30
+ """Git state of a change."""
31
+ UNSTAGED = "unstaged" # Tracked but not staged
32
+ STAGED = "staged" # Staged but not committed
33
+ UNPUSHED = "unpushed" # Committed but not pushed
34
+
35
+
36
+ @dataclass
37
+ class FileChange:
38
+ """A single file change with its state."""
39
+ path: str
40
+ state: ChangeState
41
+ change_type: str # A (added), M (modified), D (deleted), R (renamed)
42
+ insertions: int = 0
43
+ deletions: int = 0
44
+ diff_preview: str = "" # First few lines of diff
45
+ old_path: Optional[str] = None # For renames
46
+
47
+
48
+ @dataclass
49
+ class CommitChange:
50
+ """A commit that hasn't been pushed."""
51
+ sha: str
52
+ message: str
53
+ author: str
54
+ timestamp: datetime
55
+ files: list[FileChange] = field(default_factory=list)
56
+
57
+
58
+ class ChangeSummary(BaseModel):
59
+ """LLM-synthesized summary of changes using tripartite codex."""
60
+ hook: str = Field(default="", description="Engagement hook (<60 chars)")
61
+ what: str = Field(default="", description="Observable change (1 sentence)")
62
+ value: str = Field(default="", description="Why it matters (1 sentence)")
63
+ problem: str = Field(default="", description="What was broken/missing")
64
+ insight: str = Field(default="", description="Transferable lesson")
65
+ show: Optional[str] = Field(default=None, description="Visual element")
66
+
67
+
68
+ @dataclass
69
+ class ChangeReport:
70
+ """Complete change report across all git states."""
71
+ repo_path: Path
72
+ timestamp: datetime
73
+
74
+ # Changes by state
75
+ unstaged: list[FileChange] = field(default_factory=list)
76
+ staged: list[FileChange] = field(default_factory=list)
77
+ unpushed: list[CommitChange] = field(default_factory=list)
78
+
79
+ # Optional synthesis (requires LLM)
80
+ summary: Optional[ChangeSummary] = None
81
+
82
+ @property
83
+ def total_files(self) -> int:
84
+ """Total unique files changed."""
85
+ files = set()
86
+ files.update(f.path for f in self.unstaged)
87
+ files.update(f.path for f in self.staged)
88
+ for commit in self.unpushed:
89
+ files.update(f.path for f in commit.files)
90
+ return len(files)
91
+
92
+ @property
93
+ def has_changes(self) -> bool:
94
+ """Check if there are any changes."""
95
+ return bool(self.unstaged or self.staged or self.unpushed)
96
+
97
+
98
+ def get_repo(path: Path) -> Optional[Repo]:
99
+ """Get git repo at path, or None if not a repo."""
100
+ try:
101
+ return Repo(path, search_parent_directories=True)
102
+ except InvalidGitRepositoryError:
103
+ return None
104
+
105
+
106
+ def get_unstaged_changes(repo: Repo) -> list[FileChange]:
107
+ """Get tracked files with unstaged changes (modified in working tree)."""
108
+ changes = []
109
+
110
+ # Get diff between index and working tree (create_patch=True to get diff content)
111
+ diffs = repo.index.diff(None, create_patch=True)
112
+
113
+ for diff in diffs:
114
+ change_type = "M"
115
+ if diff.new_file:
116
+ change_type = "A"
117
+ elif diff.deleted_file:
118
+ change_type = "D"
119
+ elif diff.renamed:
120
+ change_type = "R"
121
+
122
+ # Get diff stats and preview
123
+ insertions = 0
124
+ deletions = 0
125
+ diff_preview = ""
126
+
127
+ try:
128
+ diff_text = diff.diff.decode("utf-8", errors="replace") if diff.diff else ""
129
+ lines = diff_text.split("\n")
130
+ insertions = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
131
+ deletions = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
132
+ # Preview: content lines (skip @@ headers, +++ and --- lines)
133
+ content_lines = [l for l in lines if (l.startswith("+") or l.startswith("-"))
134
+ and not l.startswith("+++") and not l.startswith("---")]
135
+ diff_preview = "\n".join(content_lines[:15])
136
+ except Exception:
137
+ pass
138
+
139
+ changes.append(FileChange(
140
+ path=diff.a_path or diff.b_path,
141
+ state=ChangeState.UNSTAGED,
142
+ change_type=change_type,
143
+ insertions=insertions,
144
+ deletions=deletions,
145
+ diff_preview=diff_preview,
146
+ old_path=diff.rename_from if diff.renamed else None,
147
+ ))
148
+
149
+ return changes
150
+
151
+
152
+ def get_staged_changes(repo: Repo) -> list[FileChange]:
153
+ """Get staged changes (in index, not yet committed)."""
154
+ changes = []
155
+
156
+ # Get diff between HEAD and index (create_patch=True to get diff content)
157
+ try:
158
+ diffs = repo.head.commit.diff(create_patch=True)
159
+ except ValueError:
160
+ # Empty repo, no HEAD yet
161
+ diffs = repo.index.diff(repo.head.commit, create_patch=True) if repo.head.is_valid() else []
162
+
163
+ for diff in diffs:
164
+ change_type = "M"
165
+ if diff.new_file:
166
+ change_type = "A"
167
+ elif diff.deleted_file:
168
+ change_type = "D"
169
+ elif diff.renamed:
170
+ change_type = "R"
171
+
172
+ insertions = 0
173
+ deletions = 0
174
+ diff_preview = ""
175
+
176
+ try:
177
+ diff_text = diff.diff.decode("utf-8", errors="replace") if diff.diff else ""
178
+ lines = diff_text.split("\n")
179
+ insertions = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
180
+ deletions = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
181
+ content_lines = [l for l in lines if (l.startswith("+") or l.startswith("-"))
182
+ and not l.startswith("+++") and not l.startswith("---")]
183
+ diff_preview = "\n".join(content_lines[:15])
184
+ except Exception:
185
+ pass
186
+
187
+ changes.append(FileChange(
188
+ path=diff.b_path or diff.a_path,
189
+ state=ChangeState.STAGED,
190
+ change_type=change_type,
191
+ insertions=insertions,
192
+ deletions=deletions,
193
+ diff_preview=diff_preview,
194
+ old_path=diff.rename_from if diff.renamed else None,
195
+ ))
196
+
197
+ return changes
198
+
199
+
200
+ def get_unpushed_commits(repo: Repo) -> list[CommitChange]:
201
+ """Get commits that haven't been pushed to remote."""
202
+ commits = []
203
+
204
+ # Get current branch
205
+ try:
206
+ branch = repo.active_branch
207
+ except TypeError:
208
+ # Detached HEAD
209
+ return commits
210
+
211
+ # Find tracking branch
212
+ tracking = None
213
+ try:
214
+ tracking = branch.tracking_branch()
215
+ except Exception:
216
+ pass
217
+
218
+ if not tracking:
219
+ # No upstream - check for any remote with same branch name
220
+ for remote in repo.remotes:
221
+ try:
222
+ remote_ref = f"{remote.name}/{branch.name}"
223
+ if remote_ref in [ref.name for ref in repo.refs]:
224
+ tracking = repo.refs[remote_ref]
225
+ break
226
+ except Exception:
227
+ continue
228
+
229
+ if not tracking:
230
+ # No remote tracking, can't determine unpushed
231
+ return commits
232
+
233
+ # Get commits between tracking branch and HEAD
234
+ try:
235
+ commit_range = f"{tracking.name}..HEAD"
236
+ for commit in repo.iter_commits(commit_range):
237
+ # Get file changes for this commit
238
+ file_changes = []
239
+ try:
240
+ parent = commit.parents[0] if commit.parents else None
241
+ if parent:
242
+ diffs = parent.diff(commit)
243
+ else:
244
+ # Initial commit
245
+ diffs = commit.diff(None)
246
+
247
+ for diff in diffs:
248
+ change_type = "M"
249
+ if diff.new_file:
250
+ change_type = "A"
251
+ elif diff.deleted_file:
252
+ change_type = "D"
253
+ elif diff.renamed:
254
+ change_type = "R"
255
+
256
+ file_changes.append(FileChange(
257
+ path=diff.b_path or diff.a_path,
258
+ state=ChangeState.UNPUSHED,
259
+ change_type=change_type,
260
+ old_path=diff.rename_from if diff.renamed else None,
261
+ ))
262
+ except Exception:
263
+ pass
264
+
265
+ commits.append(CommitChange(
266
+ sha=commit.hexsha[:7],
267
+ message=commit.message.split("\n")[0],
268
+ author=commit.author.name,
269
+ timestamp=datetime.fromtimestamp(commit.committed_date),
270
+ files=file_changes,
271
+ ))
272
+ except Exception:
273
+ pass
274
+
275
+ return commits
276
+
277
+
278
+ def get_change_report(path: Path) -> Optional[ChangeReport]:
279
+ """
280
+ Get comprehensive change report for a repository.
281
+
282
+ Args:
283
+ path: Path to repository or file within repository
284
+
285
+ Returns:
286
+ ChangeReport with all changes, or None if not a git repo
287
+ """
288
+ repo = get_repo(path)
289
+ if not repo:
290
+ return None
291
+
292
+ repo_path = Path(repo.working_dir)
293
+
294
+ return ChangeReport(
295
+ repo_path=repo_path,
296
+ timestamp=datetime.now(),
297
+ unstaged=get_unstaged_changes(repo),
298
+ staged=get_staged_changes(repo),
299
+ unpushed=get_unpushed_commits(repo),
300
+ )
301
+
302
+
303
+ # =============================================================================
304
+ # LLM Synthesis
305
+ # =============================================================================
306
+
307
+ CHANGE_SYNTHESIS_SYSTEM = """You analyze git changes and create a concise summary using the tripartite codex format.
308
+
309
+ Given changes across three states (unstaged, staged, unpushed commits), synthesize:
310
+
311
+ 1. HOOK (<60 chars): Engagement hook that captures the essence of the work.
312
+ Be authentic: "Finally fixing that auth bug", "The refactor nobody asked for", "Making tests actually pass"
313
+
314
+ 2. WHAT (1 sentence): The observable change - what would someone see different after these changes?
315
+
316
+ 3. VALUE (1 sentence): Why this matters - user impact, technical benefit, or strategic value.
317
+
318
+ 4. PROBLEM (1 sentence): What was broken, missing, or needed improvement.
319
+
320
+ 5. INSIGHT (1 sentence): A transferable lesson or principle from this work.
321
+
322
+ 6. SHOW (optional): A representative code snippet or before/after if it adds clarity.
323
+
324
+ Output valid JSON with these exact fields:
325
+ - "hook": string (<60 chars)
326
+ - "what": string (1 sentence)
327
+ - "value": string (1 sentence)
328
+ - "problem": string (1 sentence)
329
+ - "insight": string (1 sentence)
330
+ - "show": string or null
331
+ """
332
+
333
+ CHANGE_SYNTHESIS_USER = """Synthesize these git changes:
334
+
335
+ UNSTAGED CHANGES (modified but not staged):
336
+ {unstaged}
337
+
338
+ STAGED CHANGES (ready to commit):
339
+ {staged}
340
+
341
+ UNPUSHED COMMITS:
342
+ {unpushed}
343
+
344
+ Output valid JSON with "hook", "what", "value", "problem", "insight", and "show" fields."""
345
+
346
+
347
+ def format_file_changes(changes: list[FileChange]) -> str:
348
+ """Format file changes for LLM prompt."""
349
+ if not changes:
350
+ return "(none)"
351
+
352
+ lines = []
353
+ for c in changes:
354
+ type_icon = {"A": "+", "M": "~", "D": "-", "R": "→"}.get(c.change_type, "?")
355
+ stats = f"+{c.insertions}/-{c.deletions}" if c.insertions or c.deletions else ""
356
+ lines.append(f" {type_icon} {c.path} {stats}")
357
+ if c.diff_preview:
358
+ preview_lines = c.diff_preview.split("\n")[:5]
359
+ for pl in preview_lines:
360
+ lines.append(f" {pl}")
361
+
362
+ return "\n".join(lines)
363
+
364
+
365
+ def format_commit_changes(commits: list[CommitChange]) -> str:
366
+ """Format unpushed commits for LLM prompt."""
367
+ if not commits:
368
+ return "(none)"
369
+
370
+ lines = []
371
+ for commit in commits:
372
+ lines.append(f" [{commit.sha}] {commit.message}")
373
+ for f in commit.files[:5]:
374
+ type_icon = {"A": "+", "M": "~", "D": "-", "R": "→"}.get(f.change_type, "?")
375
+ lines.append(f" {type_icon} {f.path}")
376
+ if len(commit.files) > 5:
377
+ lines.append(f" ... and {len(commit.files) - 5} more files")
378
+
379
+ return "\n".join(lines)
380
+
381
+
382
+ GROUP_EXPLAIN_SYSTEM = """You explain git changes concisely. Given a set of file changes, provide a brief 1-2 sentence summary of what's being changed and why it might matter. Be direct and specific."""
383
+
384
+ GROUP_EXPLAIN_USER = """Explain these {group_type} changes briefly (1-2 sentences):
385
+
386
+ {changes}"""
387
+
388
+
389
+ async def explain_group(
390
+ group_type: str,
391
+ file_changes: list[FileChange] = None,
392
+ commit_changes: list[CommitChange] = None,
393
+ client=None, # AsyncOpenAI
394
+ model: str = "gpt-4o-mini",
395
+ ) -> str:
396
+ """
397
+ Explain a single group of changes.
398
+
399
+ Args:
400
+ group_type: "unstaged", "staged", or "unpushed"
401
+ file_changes: List of file changes (for unstaged/staged)
402
+ commit_changes: List of commits (for unpushed)
403
+ client: AsyncOpenAI client
404
+ model: Model to use
405
+
406
+ Returns:
407
+ Brief explanation string
408
+ """
409
+ if commit_changes:
410
+ changes_str = format_commit_changes(commit_changes)
411
+ elif file_changes:
412
+ changes_str = format_file_changes(file_changes)
413
+ else:
414
+ return ""
415
+
416
+ prompt = GROUP_EXPLAIN_USER.format(
417
+ group_type=group_type,
418
+ changes=changes_str,
419
+ )
420
+
421
+ response = await client.chat.completions.create(
422
+ model=model,
423
+ messages=[
424
+ {"role": "system", "content": GROUP_EXPLAIN_SYSTEM},
425
+ {"role": "user", "content": prompt},
426
+ ],
427
+ temperature=0.7,
428
+ )
429
+
430
+ return response.choices[0].message.content.strip()
431
+
432
+
433
+
434
+ async def synthesize_changes(
435
+ report: ChangeReport,
436
+ client, # AsyncOpenAI
437
+ model: str = "gpt-4o-mini",
438
+ ) -> ChangeSummary:
439
+ """
440
+ Use LLM to synthesize a change report into tripartite codex format.
441
+
442
+ Args:
443
+ report: The change report to synthesize
444
+ client: AsyncOpenAI client
445
+ model: Model to use
446
+
447
+ Returns:
448
+ ChangeSummary with synthesized content
449
+ """
450
+ import json
451
+
452
+ prompt = CHANGE_SYNTHESIS_USER.format(
453
+ unstaged=format_file_changes(report.unstaged),
454
+ staged=format_file_changes(report.staged),
455
+ unpushed=format_commit_changes(report.unpushed),
456
+ )
457
+
458
+ response = await client.chat.completions.create(
459
+ model=model,
460
+ messages=[
461
+ {"role": "system", "content": CHANGE_SYNTHESIS_SYSTEM},
462
+ {"role": "user", "content": prompt},
463
+ ],
464
+ response_format={"type": "json_object"},
465
+ temperature=0.7,
466
+ )
467
+
468
+ content = response.choices[0].message.content
469
+ data = json.loads(content)
470
+
471
+ return ChangeSummary(
472
+ hook=data.get("hook", ""),
473
+ what=data.get("what", ""),
474
+ value=data.get("value", ""),
475
+ problem=data.get("problem", ""),
476
+ insight=data.get("insight", ""),
477
+ show=data.get("show"),
478
+ )