stravinsky 0.1.2__py3-none-any.whl → 0.2.38__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 (42) hide show
  1. mcp_bridge/__init__.py +1 -5
  2. mcp_bridge/auth/cli.py +89 -44
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +49 -0
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/budget_optimizer.py +38 -0
  8. mcp_bridge/hooks/comment_checker.py +136 -0
  9. mcp_bridge/hooks/compaction.py +32 -0
  10. mcp_bridge/hooks/context_monitor.py +58 -0
  11. mcp_bridge/hooks/directory_context.py +40 -0
  12. mcp_bridge/hooks/edit_recovery.py +41 -0
  13. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  14. mcp_bridge/hooks/keyword_detector.py +122 -0
  15. mcp_bridge/hooks/manager.py +96 -0
  16. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  17. mcp_bridge/hooks/session_recovery.py +186 -0
  18. mcp_bridge/hooks/todo_enforcer.py +75 -0
  19. mcp_bridge/hooks/truncator.py +19 -0
  20. mcp_bridge/native_hooks/context.py +38 -0
  21. mcp_bridge/native_hooks/edit_recovery.py +46 -0
  22. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  23. mcp_bridge/native_hooks/truncator.py +23 -0
  24. mcp_bridge/prompts/delphi.py +3 -2
  25. mcp_bridge/prompts/dewey.py +105 -21
  26. mcp_bridge/prompts/stravinsky.py +452 -118
  27. mcp_bridge/server.py +491 -668
  28. mcp_bridge/server_tools.py +547 -0
  29. mcp_bridge/tools/__init__.py +13 -3
  30. mcp_bridge/tools/agent_manager.py +359 -190
  31. mcp_bridge/tools/continuous_loop.py +67 -0
  32. mcp_bridge/tools/init.py +50 -0
  33. mcp_bridge/tools/lsp/tools.py +15 -15
  34. mcp_bridge/tools/model_invoke.py +594 -48
  35. mcp_bridge/tools/skill_loader.py +51 -47
  36. mcp_bridge/tools/task_runner.py +141 -0
  37. mcp_bridge/tools/templates.py +175 -0
  38. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/METADATA +55 -10
  39. stravinsky-0.2.38.dist-info/RECORD +57 -0
  40. stravinsky-0.1.2.dist-info/RECORD +0 -32
  41. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
  42. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,186 @@
1
+ """
2
+ Session Recovery Hook.
3
+
4
+ Detects and recovers from corrupted sessions:
5
+ - Detects missing tool results after tool calls
6
+ - Injects synthetic tool_result blocks with status messages
7
+ - Enables graceful recovery
8
+ - Registered as post_tool_call hook
9
+ """
10
+
11
+ import logging
12
+ import re
13
+ import json
14
+ from typing import Any, Dict, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Patterns that indicate a tool call failure or corruption
19
+ CORRUPTION_PATTERNS = [
20
+ r"tool_result.*missing",
21
+ r"no response from tool",
22
+ r"tool call timed out",
23
+ r"connection reset",
24
+ r"unexpected end of.*response",
25
+ r"malformed.*response",
26
+ r"incomplete.*result",
27
+ r"truncated.*output",
28
+ r"<!DOCTYPE html>", # HTML error pages
29
+ r"<html>.*error",
30
+ r"500 Internal Server Error",
31
+ r"502 Bad Gateway",
32
+ r"503 Service Unavailable",
33
+ r"504 Gateway Timeout",
34
+ ]
35
+
36
+ # Patterns indicating empty or null responses
37
+ EMPTY_RESPONSE_PATTERNS = [
38
+ r"^\s*$",
39
+ r"^null$",
40
+ r"^undefined$",
41
+ r"^None$",
42
+ r"^\{\s*\}$",
43
+ r"^\[\s*\]$",
44
+ ]
45
+
46
+ # Tool-specific recovery strategies
47
+ TOOL_RECOVERY_STRATEGIES = {
48
+ "invoke_gemini": "Model invocation failed. Try reducing prompt size or switching to a different model variant.",
49
+ "invoke_openai": "Model invocation failed. Check authentication status with 'stravinsky auth status'.",
50
+ "agent_spawn": "Agent spawn failed. Check if Claude CLI is available and properly configured.",
51
+ "agent_output": "Agent output retrieval failed. The agent may still be running - try agent_progress first.",
52
+ "grep_search": "Search failed. Verify the pattern syntax and directory path.",
53
+ "ast_grep_search": "AST search failed. Ensure the language is supported and pattern is valid.",
54
+ "lsp_hover": "LSP hover failed. The language server may not be running for this file type.",
55
+ "session_read": "Session read failed. The session may have been corrupted or deleted.",
56
+ }
57
+
58
+ RECOVERY_NOTICE = """
59
+ > **[SESSION RECOVERY]**
60
+ > A tool result appears to be corrupted or incomplete.
61
+ > **Tool**: {tool_name}
62
+ > **Issue**: {issue}
63
+ > **Recovery**: {recovery_hint}
64
+ >
65
+ > The operation should be retried or an alternative approach should be used.
66
+ """
67
+
68
+ SYNTHETIC_RESULT_TEMPLATE = """
69
+ [RECOVERED TOOL RESULT]
70
+ Status: FAILED - Corrupted or incomplete response detected
71
+ Tool: {tool_name}
72
+ Original output (truncated): {truncated_output}
73
+
74
+ Recovery Hint: {recovery_hint}
75
+
76
+ Recommended Actions:
77
+ 1. Retry the tool call with the same or modified parameters
78
+ 2. Check system health with get_system_health tool
79
+ 3. If persistent, try an alternative approach
80
+ """
81
+
82
+
83
+ def detect_corruption(output: str) -> Optional[str]:
84
+ """
85
+ Detect if the output shows signs of corruption.
86
+
87
+ Returns:
88
+ Description of the corruption issue, or None if output appears valid
89
+ """
90
+ # Check for completely empty output
91
+ if not output or output.strip() == "":
92
+ return "Empty response received"
93
+
94
+ # Check for empty response patterns
95
+ for pattern in EMPTY_RESPONSE_PATTERNS:
96
+ if re.match(pattern, output.strip(), re.IGNORECASE):
97
+ return f"Empty or null response: {output[:50]}"
98
+
99
+ # Check for corruption patterns
100
+ for pattern in CORRUPTION_PATTERNS:
101
+ if re.search(pattern, output, re.IGNORECASE):
102
+ return f"Corruption pattern detected: {pattern}"
103
+
104
+ # Check for extremely short responses that might indicate truncation
105
+ # (only for tools that typically return substantial output)
106
+ if len(output.strip()) < 10 and not output.strip().startswith(("{", "[", "true", "false")):
107
+ # Could be truncated, but might also be valid short output
108
+ # Only flag if it looks like truncated text
109
+ if output.strip().endswith(("...", "---", "...)")):
110
+ return "Response appears truncated"
111
+
112
+ return None
113
+
114
+
115
+ def get_recovery_hint(tool_name: str, issue: str) -> str:
116
+ """Get a recovery hint based on the tool and issue."""
117
+ # Check for tool-specific strategy
118
+ if tool_name in TOOL_RECOVERY_STRATEGIES:
119
+ return TOOL_RECOVERY_STRATEGIES[tool_name]
120
+
121
+ # Generic recovery hints based on issue type
122
+ if "empty" in issue.lower():
123
+ return "Retry the operation. If it persists, check if the resource exists."
124
+ if "timeout" in issue.lower():
125
+ return "The operation timed out. Try with smaller input or increase timeout."
126
+ if "connection" in issue.lower():
127
+ return "Network issue detected. Check connectivity and retry."
128
+ if "500" in issue or "502" in issue or "503" in issue:
129
+ return "Server error detected. Wait a moment and retry."
130
+ if "truncated" in issue.lower():
131
+ return "Response was truncated. Try requesting smaller chunks of data."
132
+
133
+ return "Retry the operation or try an alternative approach."
134
+
135
+
136
+ async def session_recovery_hook(
137
+ tool_name: str,
138
+ arguments: Dict[str, Any],
139
+ output: str
140
+ ) -> Optional[str]:
141
+ """
142
+ Post-tool call hook that detects corrupted results and injects recovery information.
143
+
144
+ Args:
145
+ tool_name: Name of the tool that was called
146
+ arguments: Arguments passed to the tool
147
+ output: The output returned by the tool
148
+
149
+ Returns:
150
+ Modified output with recovery information, or None to keep original
151
+ """
152
+ # Detect corruption
153
+ issue = detect_corruption(output)
154
+
155
+ if not issue:
156
+ return None
157
+
158
+ logger.warning(f"[SessionRecovery] Corruption detected in {tool_name}: {issue}")
159
+
160
+ # Get recovery hint
161
+ recovery_hint = get_recovery_hint(tool_name, issue)
162
+
163
+ # Truncate original output for display
164
+ truncated_output = output[:200] + "..." if len(output) > 200 else output
165
+ truncated_output = truncated_output.replace("\n", " ").strip()
166
+
167
+ # Build synthetic result
168
+ synthetic_result = SYNTHETIC_RESULT_TEMPLATE.format(
169
+ tool_name=tool_name,
170
+ truncated_output=truncated_output,
171
+ recovery_hint=recovery_hint,
172
+ )
173
+
174
+ # Build recovery notice
175
+ recovery_notice = RECOVERY_NOTICE.format(
176
+ tool_name=tool_name,
177
+ issue=issue,
178
+ recovery_hint=recovery_hint,
179
+ )
180
+
181
+ # Return combined output
182
+ recovered_output = synthetic_result + "\n" + recovery_notice
183
+
184
+ logger.info(f"[SessionRecovery] Injected recovery guidance for {tool_name}")
185
+
186
+ return recovered_output
@@ -0,0 +1,75 @@
1
+ """
2
+ Todo Continuation Enforcer Hook.
3
+
4
+ Prevents early stopping when pending todos exist.
5
+ Injects a system reminder forcing the agent to complete all todos.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Dict, Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ TODO_CONTINUATION_REMINDER = """
14
+ [SYSTEM REMINDER - TODO CONTINUATION]
15
+
16
+ You have pending todos that are NOT yet completed. You MUST continue working.
17
+
18
+ **Pending Todos:**
19
+ {pending_todos}
20
+
21
+ **Rules:**
22
+ 1. You CANNOT stop or deliver a final answer while todos remain pending
23
+ 2. Mark each todo `in_progress` before starting, `completed` immediately after
24
+ 3. If a todo is blocked, mark it `cancelled` with explanation and create new actionable todos
25
+ 4. Only after ALL todos are `completed` or `cancelled` can you deliver your final answer
26
+
27
+ CONTINUE WORKING NOW. Do not acknowledge this message - just proceed with the next pending todo.
28
+ """
29
+
30
+
31
+ async def todo_continuation_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
32
+ """
33
+ Pre-model invoke hook that checks for pending todos.
34
+
35
+ If pending todos exist, injects a reminder into the prompt
36
+ forcing the agent to continue working.
37
+ """
38
+ prompt = params.get("prompt", "")
39
+
40
+ pending_todos = _extract_pending_todos(prompt)
41
+
42
+ if pending_todos:
43
+ logger.info(
44
+ f"[TodoEnforcer] Found {len(pending_todos)} pending todos, injecting continuation reminder"
45
+ )
46
+
47
+ todos_formatted = "\n".join(f"- [ ] {todo}" for todo in pending_todos)
48
+ reminder = TODO_CONTINUATION_REMINDER.format(pending_todos=todos_formatted)
49
+
50
+ modified_prompt = prompt + "\n\n" + reminder
51
+ params["prompt"] = modified_prompt
52
+
53
+ return params
54
+
55
+ return None
56
+
57
+
58
+ def _extract_pending_todos(prompt: str) -> list:
59
+ """
60
+ Extract pending todos from the prompt/context.
61
+ Looks for common todo patterns.
62
+ """
63
+ pending = []
64
+ lines = prompt.split("\n")
65
+
66
+ for line in lines:
67
+ stripped = line.strip()
68
+ if stripped.startswith("- [ ]") or stripped.startswith("* [ ]"):
69
+ todo_text = stripped[5:].strip()
70
+ if todo_text:
71
+ pending.append(todo_text)
72
+ elif '"status": "pending"' in stripped or '"status": "in_progress"' in stripped:
73
+ pass
74
+
75
+ return pending
@@ -0,0 +1,19 @@
1
+ """
2
+ Tool output truncator hook.
3
+ Limits the size of tool outputs to prevent context bloat.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ async def output_truncator_hook(tool_name: str, arguments: Dict[str, Any], output: str) -> Optional[str]:
9
+ """
10
+ Truncates tool output if it exceeds a certain length.
11
+ """
12
+ MAX_LENGTH = 30000 # 30k characters limit
13
+
14
+ if len(output) > MAX_LENGTH:
15
+ truncated = output[:MAX_LENGTH]
16
+ summary = f"\n\n... (Result truncated from {len(output)} chars to {MAX_LENGTH} chars) ..."
17
+ return truncated + summary
18
+
19
+ return None
@@ -0,0 +1,38 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+
6
+ def main():
7
+ try:
8
+ data = json.load(sys.stdin)
9
+ prompt = data.get("prompt", "")
10
+ except Exception:
11
+ return
12
+
13
+ cwd = Path(os.environ.get("CLAUDE_CWD", "."))
14
+
15
+ # Files to look for
16
+ context_files = ["AGENTS.md", "README.md", "CLAUDE.md"]
17
+ found_context = ""
18
+
19
+ for f in context_files:
20
+ path = cwd / f
21
+ if path.exists():
22
+ try:
23
+ content = path.read_text()
24
+ found_context += f"\n\n--- LOCAL CONTEXT: {f} ---\n{content}\n"
25
+ break # Only use one for brevity
26
+ except Exception:
27
+ pass
28
+
29
+ if found_context:
30
+ # Prepend context to prompt
31
+ # We wrap the user prompt to distinguish it
32
+ new_prompt = f"{found_context}\n\n[USER PROMPT]\n{prompt}"
33
+ print(new_prompt)
34
+ else:
35
+ print(prompt)
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1,46 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import re
5
+
6
+ def main():
7
+ # Claude Code PostToolUse inputs via Environment Variables
8
+ tool_name = os.environ.get("CLAUDE_TOOL_NAME")
9
+
10
+ # We only care about Edit/MultiEdit
11
+ if tool_name not in ["Edit", "MultiEdit"]:
12
+ return
13
+
14
+ # Read from stdin (Claude Code passes the tool response via stdin for some hook types,
15
+ # but for PostToolUse it's often better to check the environment variable if available.
16
+ # Actually, the summary says input is a JSON payload.
17
+ try:
18
+ data = json.load(sys.stdin)
19
+ tool_response = data.get("tool_response", "")
20
+ except Exception:
21
+ # Fallback to direct string if not JSON
22
+ return
23
+
24
+ # Error patterns
25
+ error_patterns = [
26
+ r"oldString not found",
27
+ r"oldString matched multiple times",
28
+ r"line numbers out of range"
29
+ ]
30
+
31
+ recovery_needed = any(re.search(p, tool_response, re.IGNORECASE) for p in error_patterns)
32
+
33
+ if recovery_needed:
34
+ correction = (
35
+ "\n\n[SYSTEM RECOVERY] It appears the Edit tool failed to find the target string. "
36
+ "Please call 'Read' on the file again to verify the current content, "
37
+ "then ensure your 'oldString' is an EXACT match including all whitespace."
38
+ )
39
+ # For PostToolUse, stdout is captured and appended/replaces output
40
+ print(tool_response + correction)
41
+ else:
42
+ # No change
43
+ print(tool_response)
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Stravinsky Mode Enforcer Hook
4
+
5
+ This PreToolUse hook blocks native file reading tools (Read, Search, Grep, Bash)
6
+ when stravinsky orchestrator mode is active, forcing use of agent_spawn instead.
7
+
8
+ Stravinsky mode is activated by creating a marker file:
9
+ ~/.stravinsky_mode
10
+
11
+ The /strav:stravinsky command should create this file, and it should be
12
+ removed when the task is complete.
13
+
14
+ Exit codes:
15
+ 0 = Allow the tool to execute
16
+ 2 = Block the tool (reason sent via stderr)
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ # Marker file that indicates stravinsky mode is active
25
+ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
26
+
27
+ # Tools to block when in stravinsky mode
28
+ BLOCKED_TOOLS = {
29
+ "Read",
30
+ "Search",
31
+ "Grep",
32
+ "Bash",
33
+ "MultiEdit",
34
+ "Edit",
35
+ }
36
+
37
+ # Tools that are always allowed
38
+ ALLOWED_TOOLS = {
39
+ "TodoRead",
40
+ "TodoWrite",
41
+ "Task",
42
+ "Agent", # MCP agent tools should be allowed
43
+ }
44
+
45
+
46
+ def is_stravinsky_mode_active() -> bool:
47
+ """Check if stravinsky orchestrator mode is active."""
48
+ return STRAVINSKY_MODE_FILE.exists()
49
+
50
+
51
+ def read_stravinsky_mode_config() -> dict:
52
+ """Read the stravinsky mode configuration if it exists."""
53
+ if not STRAVINSKY_MODE_FILE.exists():
54
+ return {}
55
+ try:
56
+ return json.loads(STRAVINSKY_MODE_FILE.read_text())
57
+ except (json.JSONDecodeError, IOError):
58
+ return {"active": True}
59
+
60
+
61
+ def main():
62
+ # Read hook input from stdin
63
+ try:
64
+ hook_input = json.loads(sys.stdin.read())
65
+ except json.JSONDecodeError:
66
+ # If we can't parse input, allow the tool
67
+ sys.exit(0)
68
+
69
+ tool_name = hook_input.get("tool_name", "")
70
+
71
+ # Always allow certain tools
72
+ if tool_name in ALLOWED_TOOLS:
73
+ sys.exit(0)
74
+
75
+ # Check if stravinsky mode is active
76
+ if not is_stravinsky_mode_active():
77
+ # Not in stravinsky mode, allow all tools
78
+ sys.exit(0)
79
+
80
+ config = read_stravinsky_mode_config()
81
+
82
+ # Check if this tool should be blocked
83
+ if tool_name in BLOCKED_TOOLS:
84
+ # Block the tool and tell Claude why
85
+ reason = f"""⚠️ STRAVINSKY MODE ACTIVE - {tool_name} BLOCKED
86
+
87
+ You are in Stravinsky orchestrator mode. Native tools are disabled.
88
+
89
+ Instead of using {tool_name}, you MUST use:
90
+ - stravinsky:agent_spawn with agent_type="explore" for file reading/searching
91
+ - stravinsky:agent_spawn with agent_type="dewey" for documentation
92
+
93
+ Example:
94
+ agent_spawn(agent_type="explore", prompt="Read and analyze the file at path/to/file.py")
95
+
96
+ To exit stravinsky mode, run:
97
+ rm ~/.stravinsky_mode
98
+ """
99
+ # Send reason to stderr (Claude sees this)
100
+ print(reason, file=sys.stderr)
101
+ # Exit with code 2 to block the tool
102
+ sys.exit(2)
103
+
104
+ # Tool not in block list, allow it
105
+ sys.exit(0)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -0,0 +1,23 @@
1
+ import os
2
+ import sys
3
+ import json
4
+
5
+ MAX_CHARS = 30000
6
+
7
+ def main():
8
+ try:
9
+ data = json.load(sys.stdin)
10
+ tool_response = data.get("tool_response", "")
11
+ except Exception:
12
+ return
13
+
14
+ if len(tool_response) > MAX_CHARS:
15
+ header = f"[TRUNCATED - {len(tool_response)} chars reduced to {MAX_CHARS}]\n"
16
+ footer = "\n...[TRUNCATED]"
17
+ truncated = tool_response[:MAX_CHARS]
18
+ print(header + truncated + footer)
19
+ else:
20
+ print(tool_response)
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -3,6 +3,7 @@ Delphi - Strategic Technical Advisor Prompt
3
3
 
4
4
  Expert technical advisor with deep reasoning for architecture decisions,
5
5
  code analysis, and engineering guidance. Uses GPT for strategic reasoning.
6
+ Aligned with Oracle from oh-my-opencode.
6
7
  """
7
8
 
8
9
  # Prompt metadata for agent routing
@@ -37,7 +38,7 @@ DELPHI_SYSTEM_PROMPT = """You are a strategic technical advisor with deep reason
37
38
 
38
39
  ## Context
39
40
 
40
- You function as an on-demand specialist invoked by a primary coding agent when complex analysis or architectural decisions require elevated reasoning. Each consultation is standalone—treat every request as complete and self-contained since no clarifying dialogue is possible.
41
+ You function as an on-demand specialist invoked by a primary coding agent (Stravinsky) when complex analysis or architectural decisions require elevated reasoning. Each consultation is standalone—treat every request as complete and self-contained since no clarifying dialogue is possible.
41
42
 
42
43
  ## What You Do
43
44
 
@@ -103,7 +104,7 @@ Your response goes directly to the user with no intermediate processing. Make yo
103
104
  def get_delphi_prompt() -> str:
104
105
  """
105
106
  Get the Delphi advisor system prompt.
106
-
107
+
107
108
  Returns:
108
109
  The full system prompt for the Delphi agent.
109
110
  """