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/cli.py +27 -1
- up/commands/dashboard.py +248 -0
- up/commands/learn.py +381 -0
- up/commands/new.py +108 -10
- up/commands/start.py +414 -0
- up/commands/status.py +205 -0
- up/commands/summarize.py +122 -0
- up/context.py +367 -0
- up/summarizer.py +407 -0
- up/templates/__init__.py +70 -2
- up/templates/config/__init__.py +502 -20
- up/templates/learn/__init__.py +567 -14
- up/templates/loop/__init__.py +480 -21
- up/templates/mcp/__init__.py +474 -0
- up/templates/projects/__init__.py +786 -0
- up_cli-0.2.0.dist-info/METADATA +374 -0
- up_cli-0.2.0.dist-info/RECORD +23 -0
- up_cli-0.1.1.dist-info/METADATA +0 -186
- up_cli-0.1.1.dist-info/RECORD +0 -14
- {up_cli-0.1.1.dist-info → up_cli-0.2.0.dist-info}/WHEEL +0 -0
- {up_cli-0.1.1.dist-info → up_cli-0.2.0.dist-info}/entry_points.txt +0 -0
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()
|
up/commands/summarize.py
ADDED
|
@@ -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)
|