up-cli 0.1.1__py3-none-any.whl → 0.2.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.
up/commands/status.py ADDED
@@ -0,0 +1,205 @@
1
+ """up status - Show system health and status."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ console = Console()
13
+
14
+
15
+ @click.command()
16
+ @click.option(
17
+ "--json", "as_json",
18
+ is_flag=True,
19
+ help="Output as JSON",
20
+ )
21
+ def status_cmd(as_json: bool):
22
+ """Show current status of all up systems.
23
+
24
+ Displays health information for:
25
+ - Context budget (token usage)
26
+ - Circuit breaker states
27
+ - Product loop progress
28
+ - Learning system state
29
+ """
30
+ cwd = Path.cwd()
31
+
32
+ status = collect_status(cwd)
33
+
34
+ if as_json:
35
+ console.print(json.dumps(status, indent=2))
36
+ return
37
+
38
+ # Display rich formatted output
39
+ display_status(status)
40
+
41
+
42
+ def collect_status(workspace: Path) -> dict:
43
+ """Collect status from all systems."""
44
+ status = {
45
+ "workspace": str(workspace),
46
+ "initialized": False,
47
+ "context_budget": None,
48
+ "loop_state": None,
49
+ "circuit_breaker": None,
50
+ "skills": [],
51
+ }
52
+
53
+ # Check if initialized
54
+ claude_dir = workspace / ".claude"
55
+ cursor_dir = workspace / ".cursor"
56
+ status["initialized"] = claude_dir.exists() or cursor_dir.exists()
57
+
58
+ if not status["initialized"]:
59
+ return status
60
+
61
+ # Context budget
62
+ context_file = workspace / ".claude/context_budget.json"
63
+ if context_file.exists():
64
+ try:
65
+ status["context_budget"] = json.loads(context_file.read_text())
66
+ except json.JSONDecodeError:
67
+ status["context_budget"] = {"error": "Invalid JSON"}
68
+
69
+ # Loop state
70
+ loop_file = workspace / ".loop_state.json"
71
+ if loop_file.exists():
72
+ try:
73
+ data = json.loads(loop_file.read_text())
74
+ status["loop_state"] = {
75
+ "iteration": data.get("iteration", 0),
76
+ "phase": data.get("phase", "UNKNOWN"),
77
+ "current_task": data.get("current_task"),
78
+ "tasks_completed": len(data.get("tasks_completed", [])),
79
+ "tasks_remaining": len(data.get("tasks_remaining", [])),
80
+ "success_rate": data.get("metrics", {}).get("success_rate", 1.0),
81
+ }
82
+ status["circuit_breaker"] = data.get("circuit_breaker", {})
83
+ except json.JSONDecodeError:
84
+ status["loop_state"] = {"error": "Invalid JSON"}
85
+
86
+ # Skills
87
+ skills_dirs = [
88
+ workspace / ".claude/skills",
89
+ workspace / ".cursor/skills",
90
+ ]
91
+ for skills_dir in skills_dirs:
92
+ if skills_dir.exists():
93
+ for skill_dir in skills_dir.iterdir():
94
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
95
+ status["skills"].append(skill_dir.name)
96
+
97
+ return status
98
+
99
+
100
+ def display_status(status: dict) -> None:
101
+ """Display status in rich format."""
102
+
103
+ # Header
104
+ workspace_name = Path(status["workspace"]).name
105
+ if status["initialized"]:
106
+ header = f"[bold green]✓[/] {workspace_name} - up systems active"
107
+ else:
108
+ header = f"[bold yellow]○[/] {workspace_name} - not initialized"
109
+ console.print(Panel(header, border_style="yellow"))
110
+ console.print("\nRun [cyan]up init[/] to initialize up systems.")
111
+ return
112
+
113
+ console.print(Panel(header, border_style="green"))
114
+
115
+ # Context Budget
116
+ console.print("\n[bold]Context Budget[/]")
117
+ if status["context_budget"]:
118
+ budget = status["context_budget"]
119
+ if "error" in budget:
120
+ console.print(f" [red]Error: {budget['error']}[/]")
121
+ else:
122
+ usage = budget.get("usage_percent", 0)
123
+ remaining = budget.get("remaining_tokens", 0)
124
+ budget_status = budget.get("status", "OK")
125
+
126
+ # Color based on status
127
+ if budget_status == "CRITICAL":
128
+ color = "red"
129
+ icon = "🔴"
130
+ elif budget_status == "WARNING":
131
+ color = "yellow"
132
+ icon = "🟡"
133
+ else:
134
+ color = "green"
135
+ icon = "🟢"
136
+
137
+ console.print(f" {icon} Status: [{color}]{budget_status}[/]")
138
+ console.print(f" Usage: {usage:.1f}% ({remaining:,} tokens remaining)")
139
+ else:
140
+ console.print(" [dim]Not configured[/]")
141
+
142
+ # Circuit Breaker
143
+ console.print("\n[bold]Circuit Breaker[/]")
144
+ if status["circuit_breaker"]:
145
+ cb = status["circuit_breaker"]
146
+ for name, state in cb.items():
147
+ if isinstance(state, dict):
148
+ cb_state = state.get("state", "UNKNOWN")
149
+ failures = state.get("failures", 0)
150
+
151
+ if cb_state == "OPEN":
152
+ icon = "🔴"
153
+ color = "red"
154
+ elif cb_state == "HALF_OPEN":
155
+ icon = "🟡"
156
+ color = "yellow"
157
+ else:
158
+ icon = "🟢"
159
+ color = "green"
160
+
161
+ console.print(f" {icon} {name}: [{color}]{cb_state}[/] (failures: {failures})")
162
+ else:
163
+ console.print(" [dim]Not active[/]")
164
+
165
+ # Loop State
166
+ console.print("\n[bold]Product Loop[/]")
167
+ if status["loop_state"]:
168
+ loop = status["loop_state"]
169
+ if "error" in loop:
170
+ console.print(f" [red]Error: {loop['error']}[/]")
171
+ else:
172
+ console.print(f" Iteration: {loop.get('iteration', 0)}")
173
+ console.print(f" Phase: {loop.get('phase', 'UNKNOWN')}")
174
+
175
+ current = loop.get("current_task")
176
+ if current:
177
+ console.print(f" Current Task: [cyan]{current}[/]")
178
+
179
+ completed = loop.get("tasks_completed", 0)
180
+ remaining = loop.get("tasks_remaining", 0)
181
+ total = completed + remaining
182
+
183
+ if total > 0:
184
+ progress = completed / total * 100
185
+ bar_len = 20
186
+ filled = int(bar_len * completed / total)
187
+ bar = "█" * filled + "░" * (bar_len - filled)
188
+ console.print(f" Progress: [{bar}] {progress:.0f}% ({completed}/{total})")
189
+
190
+ success_rate = loop.get("success_rate", 1.0)
191
+ console.print(f" Success Rate: {success_rate * 100:.0f}%")
192
+ else:
193
+ console.print(" [dim]Not active[/]")
194
+
195
+ # Skills
196
+ console.print("\n[bold]Skills[/]")
197
+ if status["skills"]:
198
+ for skill in status["skills"]:
199
+ console.print(f" • {skill}")
200
+ else:
201
+ console.print(" [dim]No skills installed[/]")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ status_cmd()
@@ -0,0 +1,122 @@
1
+ """up summarize - Summarize AI conversation history."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command()
14
+ @click.option(
15
+ "--format", "-f",
16
+ type=click.Choice(["markdown", "json"]),
17
+ default="markdown",
18
+ help="Output format",
19
+ )
20
+ @click.option(
21
+ "--output", "-o",
22
+ type=click.Path(),
23
+ help="Output file path",
24
+ )
25
+ @click.option(
26
+ "--project", "-p",
27
+ help="Filter by project path",
28
+ )
29
+ @click.option(
30
+ "--source",
31
+ type=click.Choice(["cursor", "claude", "all"]),
32
+ default="cursor",
33
+ help="Conversation source to analyze",
34
+ )
35
+ def summarize_cmd(format: str, output: str, project: str, source: str):
36
+ """Summarize AI conversation history.
37
+
38
+ Analyzes your Cursor or Claude chat history to extract:
39
+ - Common topics and patterns
40
+ - Frequent errors encountered
41
+ - Actionable insights
42
+ - Code snippets
43
+
44
+ \b
45
+ Examples:
46
+ up summarize # Markdown to stdout
47
+ up summarize -f json -o out.json # JSON to file
48
+ up summarize -p myproject # Filter by project
49
+ """
50
+ console.print(Panel.fit(
51
+ "[bold blue]Conversation Summarizer[/]",
52
+ border_style="blue"
53
+ ))
54
+
55
+ try:
56
+ if source in ("cursor", "all"):
57
+ result = _summarize_cursor(format, project)
58
+ _output_result(result, output, format)
59
+
60
+ if source == "claude":
61
+ console.print("[yellow]Claude history summarization not yet implemented.[/]")
62
+ console.print("Use [cyan]--source cursor[/] for Cursor history.")
63
+
64
+ except FileNotFoundError as e:
65
+ console.print(f"[red]Error:[/] {e}")
66
+ console.print("\nMake sure Cursor is installed and has chat history.")
67
+ sys.exit(1)
68
+ except Exception as e:
69
+ console.print(f"[red]Error:[/] {e}")
70
+ sys.exit(1)
71
+
72
+
73
+ def _summarize_cursor(format: str, project_filter: str = None) -> str:
74
+ """Summarize Cursor conversation history."""
75
+ # Add scripts to path
76
+ scripts_path = Path(__file__).parent.parent.parent.parent / "scripts"
77
+ if scripts_path.exists():
78
+ sys.path.insert(0, str(scripts_path))
79
+
80
+ try:
81
+ from export_cursor_history import load_all_data
82
+ except ImportError:
83
+ raise FileNotFoundError(
84
+ "Could not import export_cursor_history. "
85
+ "Make sure scripts/export_cursor_history.py exists."
86
+ )
87
+
88
+ from up.summarizer import ConversationSummarizer
89
+
90
+ console.print("Loading Cursor history...", style="dim")
91
+ conversations = load_all_data(project_filter=project_filter)
92
+
93
+ if not conversations:
94
+ raise ValueError("No conversations found in Cursor history.")
95
+
96
+ console.print(f"Found [cyan]{len(conversations)}[/] conversations")
97
+ console.print("Analyzing...", style="dim")
98
+
99
+ summarizer = ConversationSummarizer(conversations)
100
+
101
+ if format == "json":
102
+ return summarizer.to_json()
103
+ return summarizer.to_markdown()
104
+
105
+
106
+ def _output_result(result: str, output_path: str, format: str) -> None:
107
+ """Output result to file or stdout."""
108
+ if output_path:
109
+ Path(output_path).write_text(result)
110
+ console.print(f"\n[green]✓[/] Summary saved to [cyan]{output_path}[/]")
111
+ else:
112
+ console.print("\n")
113
+ if format == "markdown":
114
+ # Use rich markdown rendering
115
+ from rich.markdown import Markdown
116
+ console.print(Markdown(result))
117
+ else:
118
+ console.print(result)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ summarize_cmd()
up/context.py ADDED
@@ -0,0 +1,367 @@
1
+ """Context window management for AI sessions.
2
+
3
+ Tracks estimated token usage and provides warnings when approaching limits.
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field, asdict
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ # Token estimation constants (rough estimates)
15
+ CHARS_PER_TOKEN = 4 # Average characters per token
16
+ CODE_MULTIPLIER = 1.3 # Code typically uses more tokens
17
+ DEFAULT_BUDGET = 100_000 # Default context budget in tokens
18
+
19
+
20
+ @dataclass
21
+ class ContextEntry:
22
+ """A single context entry (file read, message, etc.)."""
23
+
24
+ timestamp: str
25
+ entry_type: str # 'file', 'message', 'tool_output'
26
+ source: str # File path or description
27
+ estimated_tokens: int
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+
33
+ @dataclass
34
+ class ContextBudget:
35
+ """Tracks context window usage."""
36
+
37
+ budget: int = DEFAULT_BUDGET
38
+ warning_threshold: float = 0.8 # Warn at 80%
39
+ critical_threshold: float = 0.9 # Critical at 90%
40
+ entries: list[ContextEntry] = field(default_factory=list)
41
+ total_tokens: int = 0
42
+ session_start: str = field(default_factory=lambda: datetime.now().isoformat())
43
+
44
+ @property
45
+ def usage_percent(self) -> float:
46
+ """Get usage as percentage."""
47
+ return (self.total_tokens / self.budget) * 100 if self.budget > 0 else 0
48
+
49
+ @property
50
+ def remaining_tokens(self) -> int:
51
+ """Get remaining token budget."""
52
+ return max(0, self.budget - self.total_tokens)
53
+
54
+ @property
55
+ def status(self) -> str:
56
+ """Get status: OK, WARNING, or CRITICAL."""
57
+ ratio = self.total_tokens / self.budget if self.budget > 0 else 0
58
+ if ratio >= self.critical_threshold:
59
+ return "CRITICAL"
60
+ elif ratio >= self.warning_threshold:
61
+ return "WARNING"
62
+ return "OK"
63
+
64
+ def to_dict(self) -> dict:
65
+ return {
66
+ "budget": self.budget,
67
+ "total_tokens": self.total_tokens,
68
+ "remaining_tokens": self.remaining_tokens,
69
+ "usage_percent": round(self.usage_percent, 1),
70
+ "status": self.status,
71
+ "warning_threshold": self.warning_threshold,
72
+ "critical_threshold": self.critical_threshold,
73
+ "session_start": self.session_start,
74
+ "entry_count": len(self.entries),
75
+ "entries": [e.to_dict() for e in self.entries[-20:]], # Last 20 entries
76
+ }
77
+
78
+
79
+ def estimate_tokens(text: str, is_code: bool = False) -> int:
80
+ """Estimate token count for text.
81
+
82
+ Args:
83
+ text: The text to estimate
84
+ is_code: Whether the text is code (uses higher multiplier)
85
+
86
+ Returns:
87
+ Estimated token count
88
+ """
89
+ if not text:
90
+ return 0
91
+
92
+ # Basic character-based estimation
93
+ base_tokens = len(text) / CHARS_PER_TOKEN
94
+
95
+ # Apply code multiplier if needed
96
+ if is_code:
97
+ base_tokens *= CODE_MULTIPLIER
98
+
99
+ return int(base_tokens)
100
+
101
+
102
+ def estimate_file_tokens(path: Path) -> int:
103
+ """Estimate tokens for a file.
104
+
105
+ Args:
106
+ path: Path to the file
107
+
108
+ Returns:
109
+ Estimated token count
110
+ """
111
+ if not path.exists():
112
+ return 0
113
+
114
+ try:
115
+ content = path.read_text()
116
+ except (UnicodeDecodeError, PermissionError):
117
+ return 0
118
+
119
+ # Detect if it's code
120
+ code_extensions = {
121
+ '.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rs', '.java',
122
+ '.c', '.cpp', '.h', '.hpp', '.rb', '.sh', '.bash', '.zsh'
123
+ }
124
+ is_code = path.suffix.lower() in code_extensions
125
+
126
+ return estimate_tokens(content, is_code)
127
+
128
+
129
+ class ContextManager:
130
+ """Manages context window budget for AI sessions."""
131
+
132
+ def __init__(
133
+ self,
134
+ workspace: Optional[Path] = None,
135
+ budget: int = DEFAULT_BUDGET
136
+ ):
137
+ self.workspace = workspace or Path.cwd()
138
+ self.state_file = self.workspace / ".claude" / "context_budget.json"
139
+ self.budget = ContextBudget(budget=budget)
140
+ self._load_state()
141
+
142
+ def _load_state(self) -> None:
143
+ """Load state from file."""
144
+ if self.state_file.exists():
145
+ try:
146
+ data = json.loads(self.state_file.read_text())
147
+ self.budget.budget = data.get("budget", DEFAULT_BUDGET)
148
+ self.budget.total_tokens = data.get("total_tokens", 0)
149
+ self.budget.session_start = data.get("session_start", datetime.now().isoformat())
150
+ # Reconstruct entries
151
+ entries_data = data.get("entries", [])
152
+ self.budget.entries = [
153
+ ContextEntry(**e) for e in entries_data
154
+ ]
155
+ except (json.JSONDecodeError, KeyError, TypeError):
156
+ pass
157
+
158
+ def _save_state(self) -> None:
159
+ """Save state to file."""
160
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
161
+ self.state_file.write_text(json.dumps(self.budget.to_dict(), indent=2))
162
+
163
+ def record_file_read(self, path: Path) -> ContextEntry:
164
+ """Record a file being read into context.
165
+
166
+ Args:
167
+ path: Path to the file read
168
+
169
+ Returns:
170
+ The context entry created
171
+ """
172
+ tokens = estimate_file_tokens(path)
173
+ entry = ContextEntry(
174
+ timestamp=datetime.now().isoformat(),
175
+ entry_type="file",
176
+ source=str(path),
177
+ estimated_tokens=tokens
178
+ )
179
+ self.budget.entries.append(entry)
180
+ self.budget.total_tokens += tokens
181
+ self._save_state()
182
+ return entry
183
+
184
+ def record_message(self, message: str, role: str = "user") -> ContextEntry:
185
+ """Record a message in context.
186
+
187
+ Args:
188
+ message: The message content
189
+ role: 'user' or 'assistant'
190
+
191
+ Returns:
192
+ The context entry created
193
+ """
194
+ tokens = estimate_tokens(message)
195
+ entry = ContextEntry(
196
+ timestamp=datetime.now().isoformat(),
197
+ entry_type="message",
198
+ source=f"{role} message",
199
+ estimated_tokens=tokens
200
+ )
201
+ self.budget.entries.append(entry)
202
+ self.budget.total_tokens += tokens
203
+ self._save_state()
204
+ return entry
205
+
206
+ def record_tool_output(self, tool: str, output_size: int) -> ContextEntry:
207
+ """Record tool output in context.
208
+
209
+ Args:
210
+ tool: Name of the tool
211
+ output_size: Size of output in characters
212
+
213
+ Returns:
214
+ The context entry created
215
+ """
216
+ tokens = estimate_tokens("x" * output_size) # Rough estimate
217
+ entry = ContextEntry(
218
+ timestamp=datetime.now().isoformat(),
219
+ entry_type="tool_output",
220
+ source=f"tool:{tool}",
221
+ estimated_tokens=tokens
222
+ )
223
+ self.budget.entries.append(entry)
224
+ self.budget.total_tokens += tokens
225
+ self._save_state()
226
+ return entry
227
+
228
+ def get_status(self) -> dict:
229
+ """Get current context budget status.
230
+
231
+ Returns:
232
+ Status dictionary with usage info
233
+ """
234
+ return self.budget.to_dict()
235
+
236
+ def check_budget(self) -> tuple[str, str]:
237
+ """Check budget and return status with message.
238
+
239
+ Returns:
240
+ Tuple of (status, message)
241
+ """
242
+ status = self.budget.status
243
+ usage = self.budget.usage_percent
244
+ remaining = self.budget.remaining_tokens
245
+
246
+ if status == "CRITICAL":
247
+ msg = (
248
+ f"⚠️ CRITICAL: Context at {usage:.1f}% ({remaining:,} tokens remaining). "
249
+ "Consider summarizing and creating a checkpoint."
250
+ )
251
+ elif status == "WARNING":
252
+ msg = (
253
+ f"⚡ WARNING: Context at {usage:.1f}% ({remaining:,} tokens remaining). "
254
+ "Start planning for handoff."
255
+ )
256
+ else:
257
+ msg = f"✅ OK: Context at {usage:.1f}% ({remaining:,} tokens remaining)."
258
+
259
+ return status, msg
260
+
261
+ def reset(self) -> None:
262
+ """Reset context budget for new session."""
263
+ self.budget = ContextBudget(budget=self.budget.budget)
264
+ self._save_state()
265
+
266
+ def estimate_file_impact(self, path: Path) -> dict:
267
+ """Estimate impact of reading a file on budget.
268
+
269
+ Args:
270
+ path: Path to the file
271
+
272
+ Returns:
273
+ Impact analysis dictionary
274
+ """
275
+ tokens = estimate_file_tokens(path)
276
+ new_total = self.budget.total_tokens + tokens
277
+ new_percent = (new_total / self.budget.budget) * 100 if self.budget.budget > 0 else 0
278
+
279
+ return {
280
+ "file": str(path),
281
+ "estimated_tokens": tokens,
282
+ "current_total": self.budget.total_tokens,
283
+ "new_total": new_total,
284
+ "current_percent": round(self.budget.usage_percent, 1),
285
+ "new_percent": round(new_percent, 1),
286
+ "will_exceed_warning": new_percent >= self.budget.warning_threshold * 100,
287
+ "will_exceed_critical": new_percent >= self.budget.critical_threshold * 100,
288
+ }
289
+
290
+ def suggest_files_to_drop(self, target_reduction: int) -> list[str]:
291
+ """Suggest files that could be dropped to reduce context.
292
+
293
+ Args:
294
+ target_reduction: Target token reduction
295
+
296
+ Returns:
297
+ List of file paths to consider dropping
298
+ """
299
+ # Get file entries sorted by tokens (largest first)
300
+ file_entries = [
301
+ e for e in self.budget.entries
302
+ if e.entry_type == "file"
303
+ ]
304
+ file_entries.sort(key=lambda e: e.estimated_tokens, reverse=True)
305
+
306
+ suggestions = []
307
+ reduction = 0
308
+
309
+ for entry in file_entries:
310
+ if reduction >= target_reduction:
311
+ break
312
+ suggestions.append(entry.source)
313
+ reduction += entry.estimated_tokens
314
+
315
+ return suggestions
316
+
317
+
318
+ def create_context_budget_file(target_dir: Path, budget: int = DEFAULT_BUDGET) -> Path:
319
+ """Create initial context budget file for a project.
320
+
321
+ Args:
322
+ target_dir: Project directory
323
+ budget: Token budget
324
+
325
+ Returns:
326
+ Path to created file
327
+ """
328
+ manager = ContextManager(workspace=target_dir, budget=budget)
329
+ manager.reset()
330
+ return manager.state_file
331
+
332
+
333
+ # CLI integration
334
+ if __name__ == "__main__":
335
+ import sys
336
+
337
+ manager = ContextManager()
338
+
339
+ if len(sys.argv) > 1:
340
+ cmd = sys.argv[1]
341
+
342
+ if cmd == "status":
343
+ status, msg = manager.check_budget()
344
+ print(msg)
345
+ print(json.dumps(manager.get_status(), indent=2))
346
+
347
+ elif cmd == "reset":
348
+ manager.reset()
349
+ print("Context budget reset for new session.")
350
+
351
+ elif cmd == "estimate" and len(sys.argv) > 2:
352
+ path = Path(sys.argv[2])
353
+ impact = manager.estimate_file_impact(path)
354
+ print(json.dumps(impact, indent=2))
355
+
356
+ elif cmd == "record" and len(sys.argv) > 2:
357
+ path = Path(sys.argv[2])
358
+ entry = manager.record_file_read(path)
359
+ print(f"Recorded: {entry.source} ({entry.estimated_tokens} tokens)")
360
+ status, msg = manager.check_budget()
361
+ print(msg)
362
+
363
+ else:
364
+ print("Usage: python context.py [status|reset|estimate <file>|record <file>]")
365
+ else:
366
+ status, msg = manager.check_budget()
367
+ print(msg)