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.

Files changed (190) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +112 -11
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {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}"