stravinsky 0.2.67__py3-none-any.whl → 0.4.66__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.
Potentially problematic release.
This version of stravinsky might be problematic. Click here for more details.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +112 -11
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
- mcp_bridge/config/README.md +276 -0
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +247 -0
- mcp_bridge/config/hooks_manifest.json +138 -0
- mcp_bridge/config/rate_limits.py +317 -0
- mcp_bridge/config/skills_manifest.json +128 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +19 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +43 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/tool_messaging.py +113 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +150 -0
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +542 -59
- mcp_bridge/server_tools.py +738 -6
- mcp_bridge/tools/__init__.py +40 -25
- mcp_bridge/tools/agent_manager.py +616 -697
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +70 -53
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +12 -5
- mcp_bridge/tools/lsp/manager.py +471 -0
- mcp_bridge/tools/lsp/tools.py +723 -207
- mcp_bridge/tools/model_invoke.py +1195 -273
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +406 -0
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +3627 -0
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +585 -0
- mcp_bridge/update_manager_pypi.py +297 -0
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- stravinsky-0.4.66.dist-info/METADATA +517 -0
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.2.67.dist-info/METADATA +0 -284
- stravinsky-0.2.67.dist-info/RECORD +0 -76
- {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyPI Update Manager for Stravinsky MCP server.
|
|
3
|
+
|
|
4
|
+
Checks PyPI for new versions with throttling to prevent excessive API calls.
|
|
5
|
+
Logs all checks to ~/.stravinsky/update.log for debugging and monitoring.
|
|
6
|
+
Non-blocking background update checks on server startup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Get the logger for this module
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Import version from main module
|
|
19
|
+
from mcp_bridge import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_stravinsky_home() -> Path | None:
|
|
23
|
+
"""Get or create ~/.stravinsky directory."""
|
|
24
|
+
home_dir = Path.home() / ".stravinsky"
|
|
25
|
+
try:
|
|
26
|
+
home_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return home_dir
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.warning(f"Failed to create ~/.stravinsky directory: {e}")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_last_check_time() -> datetime | None:
|
|
34
|
+
"""
|
|
35
|
+
Read the last update check time from ~/.stravinsky/update.log.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
datetime of last check, or None if file doesn't exist or is invalid
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
home_dir = _get_stravinsky_home()
|
|
42
|
+
if not home_dir:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
update_log = home_dir / "update.log"
|
|
46
|
+
if not update_log.exists():
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# Read the last line (most recent check)
|
|
50
|
+
with open(update_log) as f:
|
|
51
|
+
lines = f.readlines()
|
|
52
|
+
if not lines:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
last_line = lines[-1].strip()
|
|
56
|
+
if not last_line:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Parse format: YYYY-MM-DD HH:MM:SS | VERSION_CHECK | ...
|
|
60
|
+
parts = last_line.split(" | ")
|
|
61
|
+
if len(parts) < 1:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
timestamp_str = parts[0]
|
|
65
|
+
last_check = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
|
|
66
|
+
return last_check
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.debug(f"Failed to read last check time: {e}")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _should_check(last_check_time: datetime | None) -> bool:
|
|
74
|
+
"""
|
|
75
|
+
Determine if enough time has passed since the last check.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
last_check_time: datetime of last check, or None
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if 24+ hours have passed or no prior check exists
|
|
82
|
+
"""
|
|
83
|
+
if last_check_time is None:
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
now = datetime.now()
|
|
87
|
+
time_since_last_check = now - last_check_time
|
|
88
|
+
|
|
89
|
+
# Check if 24+ hours have passed
|
|
90
|
+
return time_since_last_check >= timedelta(hours=24)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_pypi_version() -> str | None:
|
|
94
|
+
"""
|
|
95
|
+
Fetch the latest version of stravinsky from PyPI.
|
|
96
|
+
|
|
97
|
+
Uses: pip index versions stravinsky
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Version string (e.g., "0.3.10"), or None if unable to fetch
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
# Run: pip index versions stravinsky
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
[sys.executable, "-m", "pip", "index", "versions", "stravinsky"],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=10,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if result.returncode != 0:
|
|
112
|
+
logger.debug(f"pip index versions failed: {result.stderr}")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# Parse output: first line is "Available versions: X.Y.Z, A.B.C, ..."
|
|
116
|
+
output = result.stdout.strip()
|
|
117
|
+
if not output:
|
|
118
|
+
logger.debug("pip index versions returned empty output")
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
# Extract available versions line
|
|
122
|
+
lines = output.split("\n")
|
|
123
|
+
for line in lines:
|
|
124
|
+
if line.startswith("Available versions:"):
|
|
125
|
+
# Format: "Available versions: 0.3.10, 0.3.9, 0.3.8, ..."
|
|
126
|
+
versions_part = line.replace("Available versions:", "").strip()
|
|
127
|
+
versions = [v.strip() for v in versions_part.split(",")]
|
|
128
|
+
|
|
129
|
+
if versions:
|
|
130
|
+
latest = versions[0]
|
|
131
|
+
logger.debug(f"Latest version on PyPI: {latest}")
|
|
132
|
+
return latest
|
|
133
|
+
|
|
134
|
+
logger.debug(f"Could not parse pip output: {output}")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
logger.warning("pip index versions timed out after 10 seconds")
|
|
139
|
+
return None
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.debug(f"Failed to fetch PyPI version: {e}")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _compare_versions(current: str, latest: str) -> bool:
|
|
146
|
+
"""
|
|
147
|
+
Compare semantic versions.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
current: Current version string (e.g., "0.3.9")
|
|
151
|
+
latest: Latest version string (e.g., "0.3.10")
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if latest > current, False otherwise
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
# Parse versions as tuples of integers
|
|
158
|
+
current_parts = [int(x) for x in current.split(".")]
|
|
159
|
+
latest_parts = [int(x) for x in latest.split(".")]
|
|
160
|
+
|
|
161
|
+
# Pad shorter version with zeros
|
|
162
|
+
max_len = max(len(current_parts), len(latest_parts))
|
|
163
|
+
current_parts += [0] * (max_len - len(current_parts))
|
|
164
|
+
latest_parts += [0] * (max_len - len(latest_parts))
|
|
165
|
+
|
|
166
|
+
# Tuple comparison works element-by-element
|
|
167
|
+
return tuple(latest_parts) > tuple(current_parts)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.debug(f"Failed to compare versions '{current}' and '{latest}': {e}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _log_check(current: str, latest: str | None, status: str) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Log the update check to ~/.stravinsky/update.log.
|
|
177
|
+
|
|
178
|
+
Format: YYYY-MM-DD HH:MM:SS | VERSION_CHECK | current=X.Y.Z pypi=A.B.C | <status>
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
current: Current version
|
|
182
|
+
latest: Latest version from PyPI (or None)
|
|
183
|
+
status: Check status ("new_available", "up_to_date", "error", etc.)
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
home_dir = _get_stravinsky_home()
|
|
187
|
+
if not home_dir:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
update_log = home_dir / "update.log"
|
|
191
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
192
|
+
|
|
193
|
+
if latest:
|
|
194
|
+
log_entry = f"{timestamp} | VERSION_CHECK | current={current} pypi={latest} | {status}"
|
|
195
|
+
else:
|
|
196
|
+
log_entry = f"{timestamp} | VERSION_CHECK | current={current} pypi=unknown | {status}"
|
|
197
|
+
|
|
198
|
+
with open(update_log, "a") as f:
|
|
199
|
+
f.write(log_entry + "\n")
|
|
200
|
+
|
|
201
|
+
logger.debug(f"Logged update check: {status}")
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.warning(f"Failed to log update check: {e}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def check_for_updates(skip_updates: bool = False) -> dict:
|
|
208
|
+
"""
|
|
209
|
+
Check PyPI for new versions of stravinsky.
|
|
210
|
+
|
|
211
|
+
Implements 24-hour throttling to prevent excessive API calls.
|
|
212
|
+
All failures are handled gracefully without raising exceptions.
|
|
213
|
+
Non-blocking - safe to run via asyncio.create_task().
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
skip_updates: If True, skip the check entirely
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
dict with keys:
|
|
220
|
+
- status: "checked" | "skipped" | "error"
|
|
221
|
+
- current: current version (e.g., "0.3.9")
|
|
222
|
+
- latest: latest version (e.g., "0.3.10") or None
|
|
223
|
+
- update_available: bool
|
|
224
|
+
- message: str (optional, for errors)
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
# Get current version
|
|
228
|
+
current_version = __version__
|
|
229
|
+
|
|
230
|
+
# Return early if updates are skipped
|
|
231
|
+
if skip_updates:
|
|
232
|
+
logger.debug("Update check skipped (skip_updates=True)")
|
|
233
|
+
return {
|
|
234
|
+
"status": "skipped",
|
|
235
|
+
"current": current_version,
|
|
236
|
+
"latest": None,
|
|
237
|
+
"update_available": False,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# Check if enough time has passed since last check
|
|
241
|
+
last_check_time = _get_last_check_time()
|
|
242
|
+
if not _should_check(last_check_time):
|
|
243
|
+
logger.debug("Update check throttled (24-hour limit)")
|
|
244
|
+
return {
|
|
245
|
+
"status": "skipped",
|
|
246
|
+
"current": current_version,
|
|
247
|
+
"latest": None,
|
|
248
|
+
"update_available": False,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Fetch latest version from PyPI
|
|
252
|
+
latest_version = _get_pypi_version()
|
|
253
|
+
|
|
254
|
+
if latest_version is None:
|
|
255
|
+
logger.warning("Failed to fetch latest version from PyPI")
|
|
256
|
+
_log_check(current_version, None, "error")
|
|
257
|
+
return {
|
|
258
|
+
"status": "error",
|
|
259
|
+
"current": current_version,
|
|
260
|
+
"latest": None,
|
|
261
|
+
"update_available": False,
|
|
262
|
+
"message": "Failed to fetch version from PyPI",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
# Compare versions
|
|
266
|
+
update_available = _compare_versions(current_version, latest_version)
|
|
267
|
+
|
|
268
|
+
# Determine status
|
|
269
|
+
if update_available:
|
|
270
|
+
status = "new_available"
|
|
271
|
+
logger.info(
|
|
272
|
+
f"Update available: {current_version} -> {latest_version}. "
|
|
273
|
+
f"Install with: pip install --upgrade stravinsky"
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
status = "up_to_date"
|
|
277
|
+
logger.debug(f"Stravinsky is up to date ({current_version})")
|
|
278
|
+
|
|
279
|
+
# Log the check
|
|
280
|
+
_log_check(current_version, latest_version, status)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"status": "checked",
|
|
284
|
+
"current": current_version,
|
|
285
|
+
"latest": latest_version,
|
|
286
|
+
"update_available": update_available,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(f"Unexpected error during update check: {e}", exc_info=True)
|
|
291
|
+
return {
|
|
292
|
+
"status": "error",
|
|
293
|
+
"current": __version__,
|
|
294
|
+
"latest": None,
|
|
295
|
+
"update_available": False,
|
|
296
|
+
"message": f"Update check failed: {str(e)}",
|
|
297
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
class IOCache:
|
|
7
|
+
"""
|
|
8
|
+
Lightweight, thread-safe in-memory cache for I/O operations.
|
|
9
|
+
Supports TTL-based expiration and manual invalidation.
|
|
10
|
+
"""
|
|
11
|
+
_instance = None
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
def __init__(self, ttl: float = 5.0):
|
|
15
|
+
self.ttl = ttl
|
|
16
|
+
self._cache: Dict[str, Tuple[Any, float]] = {}
|
|
17
|
+
self._cache_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def get_instance(cls):
|
|
21
|
+
"""Get the singleton instance of IOCache."""
|
|
22
|
+
with cls._lock:
|
|
23
|
+
if cls._instance is None:
|
|
24
|
+
cls._instance = cls()
|
|
25
|
+
return cls._instance
|
|
26
|
+
|
|
27
|
+
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Store a value in the cache.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
key: Cache key (e.g., file path or command).
|
|
33
|
+
value: Data to cache.
|
|
34
|
+
ttl: Optional override for the default TTL.
|
|
35
|
+
"""
|
|
36
|
+
expiry = time.time() + (ttl if ttl is not None else self.ttl)
|
|
37
|
+
with self._cache_lock:
|
|
38
|
+
self._cache[key] = (value, expiry)
|
|
39
|
+
|
|
40
|
+
def get(self, key: str) -> Optional[Any]:
|
|
41
|
+
"""
|
|
42
|
+
Retrieve a value from the cache if it hasn't expired.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The cached value, or None if missing or expired.
|
|
46
|
+
"""
|
|
47
|
+
with self._cache_lock:
|
|
48
|
+
if key not in self._cache:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
value, expiry = self._cache[key]
|
|
52
|
+
if time.time() > expiry:
|
|
53
|
+
del self._cache[key]
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def invalidate(self, key: str) -> None:
|
|
59
|
+
"""Remove a specific key from the cache."""
|
|
60
|
+
with self._cache_lock:
|
|
61
|
+
if key in self._cache:
|
|
62
|
+
del self._cache[key]
|
|
63
|
+
|
|
64
|
+
def invalidate_path(self, path: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Invalidate all cache entries related to a specific file path.
|
|
67
|
+
Matches keys for read_file, list_dir, etc.
|
|
68
|
+
"""
|
|
69
|
+
# Use realpath to resolve symlinks (crucial for macOS /var -> /private/var)
|
|
70
|
+
abs_path = os.path.realpath(path)
|
|
71
|
+
with self._cache_lock:
|
|
72
|
+
keys_to_del = [
|
|
73
|
+
k for k in self._cache.keys()
|
|
74
|
+
if abs_path in k
|
|
75
|
+
]
|
|
76
|
+
for k in keys_to_del:
|
|
77
|
+
del self._cache[k]
|
|
78
|
+
|
|
79
|
+
def clear(self) -> None:
|
|
80
|
+
"""Clear all cached entries."""
|
|
81
|
+
with self._cache_lock:
|
|
82
|
+
self._cache.clear()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, List, Union
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ProcessResult:
|
|
8
|
+
returncode: int
|
|
9
|
+
stdout: str
|
|
10
|
+
stderr: str
|
|
11
|
+
|
|
12
|
+
async def async_execute(
|
|
13
|
+
cmd: Union[str, List[str]],
|
|
14
|
+
cwd: Optional[str] = None,
|
|
15
|
+
timeout: Optional[float] = None
|
|
16
|
+
) -> ProcessResult:
|
|
17
|
+
"""
|
|
18
|
+
Execute a subprocess asynchronously.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
cmd: Command string or list of arguments.
|
|
22
|
+
cwd: Working directory.
|
|
23
|
+
timeout: Maximum execution time in seconds.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
ProcessResult containing exit code, stdout, and stderr.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
asyncio.TimeoutError: If the process exceeds the timeout.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(cmd, str):
|
|
32
|
+
# Use shell wrapper for string commands
|
|
33
|
+
process = await asyncio.create_subprocess_exec(
|
|
34
|
+
"bash", "-c", cmd,
|
|
35
|
+
stdout=asyncio.subprocess.PIPE,
|
|
36
|
+
stderr=asyncio.subprocess.PIPE,
|
|
37
|
+
cwd=cwd
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
# Use direct execution for list of arguments
|
|
41
|
+
process = await asyncio.create_subprocess_exec(
|
|
42
|
+
*cmd,
|
|
43
|
+
stdout=asyncio.subprocess.PIPE,
|
|
44
|
+
stderr=asyncio.subprocess.PIPE,
|
|
45
|
+
cwd=cwd
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
if timeout:
|
|
50
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
51
|
+
else:
|
|
52
|
+
stdout_bytes, stderr_bytes = await process.communicate()
|
|
53
|
+
|
|
54
|
+
except asyncio.TimeoutError:
|
|
55
|
+
try:
|
|
56
|
+
# Kill process group if started with bash
|
|
57
|
+
if isinstance(cmd, str):
|
|
58
|
+
process.kill()
|
|
59
|
+
else:
|
|
60
|
+
process.kill()
|
|
61
|
+
except ProcessLookupError:
|
|
62
|
+
pass # Already gone
|
|
63
|
+
# Wait for it to actually die to avoid zombies
|
|
64
|
+
await process.wait()
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
return ProcessResult(
|
|
68
|
+
returncode=process.returncode if process.returncode is not None else 0,
|
|
69
|
+
stdout=stdout_bytes.decode('utf-8', errors='replace'),
|
|
70
|
+
stderr=stderr_bytes.decode('utf-8', errors='replace')
|
|
71
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
STATE_DIR = Path.home() / ".stravinsky" / "state"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_state_dir():
|
|
11
|
+
"""Ensure the state directory exists."""
|
|
12
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_session_state_path(session_id: str) -> Path:
|
|
16
|
+
"""Get the path to the state file for a given session."""
|
|
17
|
+
ensure_state_dir()
|
|
18
|
+
# Sanitize session_id to avoid path traversal
|
|
19
|
+
safe_id = "".join(c for c in session_id if c.isalnum() or c in ("-", "_"))
|
|
20
|
+
return STATE_DIR / f"session_{safe_id}.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_session_state(session_id: Optional[str] = None) -> dict[str, Any]:
|
|
24
|
+
"""Get the state for a session."""
|
|
25
|
+
if session_id is None:
|
|
26
|
+
session_id = get_current_session_id()
|
|
27
|
+
|
|
28
|
+
path = get_session_state_path(session_id)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(path.read_text())
|
|
33
|
+
except Exception:
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def update_session_state(updates: dict[str, Any], session_id: Optional[str] = None):
|
|
38
|
+
"""Update the state for a session."""
|
|
39
|
+
if session_id is None:
|
|
40
|
+
session_id = get_current_session_id()
|
|
41
|
+
|
|
42
|
+
state = get_session_state(session_id)
|
|
43
|
+
state.update(updates)
|
|
44
|
+
state["updated_at"] = time.time()
|
|
45
|
+
path = get_session_state_path(session_id)
|
|
46
|
+
path.write_text(json.dumps(state, indent=2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_current_session_id() -> str:
|
|
50
|
+
"""Get the current session ID from environment or default."""
|
|
51
|
+
return os.environ.get("CLAUDE_SESSION_ID", "default")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import stravinsky_native
|
|
7
|
+
from stravinsky_native import truncator
|
|
8
|
+
NATIVE_AVAILABLE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
NATIVE_AVAILABLE = False
|
|
11
|
+
logging.warning("stravinsky_native not found. Native truncation unavailable.")
|
|
12
|
+
|
|
13
|
+
class TruncationStrategy(Enum):
|
|
14
|
+
MIDDLE = "middle"
|
|
15
|
+
TAIL = "tail"
|
|
16
|
+
AUTO_TAIL = "auto_tail" # New line-based strategy
|
|
17
|
+
|
|
18
|
+
def truncate_output(
|
|
19
|
+
text: str,
|
|
20
|
+
limit: int = 20000,
|
|
21
|
+
strategy: TruncationStrategy = TruncationStrategy.MIDDLE,
|
|
22
|
+
custom_guidance: Optional[str] = None
|
|
23
|
+
) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Truncates text to a specific limit using the chosen strategy.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
text: The string to truncate.
|
|
29
|
+
limit: Max characters allowed.
|
|
30
|
+
strategy: How to truncate (MIDDLE or TAIL).
|
|
31
|
+
custom_guidance: Optional extra message for the agent.
|
|
32
|
+
"""
|
|
33
|
+
if len(text) <= limit:
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
# Standard guidance messages
|
|
37
|
+
guidance = "\n\n[Output truncated. "
|
|
38
|
+
if custom_guidance:
|
|
39
|
+
guidance += custom_guidance + " "
|
|
40
|
+
|
|
41
|
+
if strategy == TruncationStrategy.TAIL:
|
|
42
|
+
# Show the END of the file
|
|
43
|
+
truncated_text = text[-limit:]
|
|
44
|
+
msg = f"{guidance}Showing last {limit} characters. Use offset/limit parameters to read specific parts of the file.]"
|
|
45
|
+
return f"... [TRUNCATED] ...\n{truncated_text}{msg}"
|
|
46
|
+
|
|
47
|
+
else: # MIDDLE strategy
|
|
48
|
+
# Show start and end, snip the middle
|
|
49
|
+
half_limit = limit // 2
|
|
50
|
+
start_part = text[:half_limit]
|
|
51
|
+
end_part = text[-half_limit:]
|
|
52
|
+
msg = f"{guidance}Showing first and last {half_limit} characters. Use offset/limit parameters to read specific parts of the file.]"
|
|
53
|
+
return f"{start_part}\n\n[... content truncated ...]\n\n{end_part}{msg}"
|
|
54
|
+
|
|
55
|
+
def auto_tail_logs(content: str, head_lines: int = 100, tail_lines: int = 100) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Smart truncation for logs using native Rust implementation.
|
|
58
|
+
Keeps the first N lines (setup/config) and last M lines (recent errors).
|
|
59
|
+
"""
|
|
60
|
+
if NATIVE_AVAILABLE:
|
|
61
|
+
try:
|
|
62
|
+
return truncator.auto_tail(content, head_lines, tail_lines)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logging.error(f"Native truncation failed: {e}")
|
|
65
|
+
# Fallback to python implementation
|
|
66
|
+
|
|
67
|
+
# Python fallback
|
|
68
|
+
lines = content.splitlines()
|
|
69
|
+
if len(lines) <= head_lines + tail_lines:
|
|
70
|
+
return content
|
|
71
|
+
|
|
72
|
+
head = "\n".join(lines[:head_lines])
|
|
73
|
+
tail = "\n".join(lines[-tail_lines:])
|
|
74
|
+
hidden = len(lines) - (head_lines + tail_lines)
|
|
75
|
+
|
|
76
|
+
return f"{head}\n\n<... {hidden} lines truncated (fallback) ...>\n\n{tail}"
|