stravinsky 0.4.18__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 +0 -1
- 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/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +3 -5
- mcp_bridge/config/rate_limits.py +108 -13
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +14 -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 +35 -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/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 +3 -4
- 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 +363 -34
- mcp_bridge/server_tools.py +298 -6
- mcp_bridge/tools/__init__.py +19 -8
- mcp_bridge/tools/agent_manager.py +549 -799
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +54 -51
- 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 +8 -8
- mcp_bridge/tools/lsp/manager.py +51 -28
- mcp_bridge/tools/lsp/tools.py +98 -65
- mcp_bridge/tools/model_invoke.py +1047 -152
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +132 -49
- 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 +677 -92
- 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 +33 -37
- mcp_bridge/update_manager_pypi.py +6 -8
- 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.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.4.18.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.4.18.dist-info/RECORD +0 -88
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
mcp_bridge/hooks/tmux_manager.py
CHANGED
|
@@ -5,19 +5,18 @@ Manages persistent tmux sessions and cleanup.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
import re
|
|
9
8
|
import shlex
|
|
10
9
|
import subprocess
|
|
11
|
-
from typing import Any
|
|
10
|
+
from typing import Any
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
15
14
|
# Track tmux sessions created by Stravinsky
|
|
16
|
-
_tracked_sessions:
|
|
15
|
+
_tracked_sessions: set[str] = set()
|
|
17
16
|
SESSION_PREFIX = "stravinsky-"
|
|
18
17
|
|
|
19
18
|
|
|
20
|
-
def parse_tmux_command(command: str) ->
|
|
19
|
+
def parse_tmux_command(command: str) -> str | None:
|
|
21
20
|
"""
|
|
22
21
|
Parse tmux command to extract session name.
|
|
23
22
|
|
|
@@ -65,8 +64,8 @@ def normalize_session_name(name: str) -> str:
|
|
|
65
64
|
|
|
66
65
|
|
|
67
66
|
async def tmux_manager_hook(
|
|
68
|
-
tool_name: str, tool_input:
|
|
69
|
-
) ->
|
|
67
|
+
tool_name: str, tool_input: dict[str, Any], tool_output: str | None = None
|
|
68
|
+
) -> str | None:
|
|
70
69
|
"""
|
|
71
70
|
Post-tool-call hook that tracks tmux sessions.
|
|
72
71
|
|
|
@@ -100,7 +99,7 @@ async def tmux_manager_hook(
|
|
|
100
99
|
return tool_output
|
|
101
100
|
|
|
102
101
|
|
|
103
|
-
def cleanup_tmux_sessions() ->
|
|
102
|
+
def cleanup_tmux_sessions() -> list[str]:
|
|
104
103
|
"""
|
|
105
104
|
Kill all tracked tmux sessions.
|
|
106
105
|
|
|
@@ -134,7 +133,7 @@ def cleanup_tmux_sessions() -> List[str]:
|
|
|
134
133
|
return killed
|
|
135
134
|
|
|
136
135
|
|
|
137
|
-
def get_tracked_sessions() ->
|
|
136
|
+
def get_tracked_sessions() -> set[str]:
|
|
138
137
|
"""
|
|
139
138
|
Get set of currently tracked tmux sessions.
|
|
140
139
|
"""
|
|
@@ -12,6 +12,7 @@ Works in tandem with:
|
|
|
12
12
|
- parallel_execution.py (UserPromptSubmit): Pre-emptive instruction injection
|
|
13
13
|
- stravinsky_mode.py (PreToolUse): Hard blocking of Read/Grep/Bash tools
|
|
14
14
|
"""
|
|
15
|
+
|
|
15
16
|
import json
|
|
16
17
|
import sys
|
|
17
18
|
from pathlib import Path
|
|
@@ -77,7 +78,9 @@ DO NOT:
|
|
|
77
78
|
|
|
78
79
|
Your NEXT action MUST be multiple Task() calls, one for each independent TODO.
|
|
79
80
|
"""
|
|
80
|
-
|
|
81
|
+
# CRITICAL: Output to stdout so Claude sees the message
|
|
82
|
+
# stderr is not reliably injected into the conversation
|
|
83
|
+
print(error_message)
|
|
81
84
|
|
|
82
85
|
# Exit code 2 = HARD BLOCK in stravinsky mode
|
|
83
86
|
# Exit code 1 = WARNING otherwise
|
|
@@ -3,49 +3,84 @@ Todo Continuation Enforcer Hook.
|
|
|
3
3
|
|
|
4
4
|
Prevents early stopping when pending todos exist.
|
|
5
5
|
Injects a system reminder forcing the agent to complete all todos.
|
|
6
|
+
Includes evidence extraction and verification to prevent vague completion claims.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import logging
|
|
9
|
-
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
10
12
|
|
|
11
13
|
logger = logging.getLogger(__name__)
|
|
12
14
|
|
|
13
15
|
TODO_CONTINUATION_REMINDER = """
|
|
14
|
-
[SYSTEM REMINDER - TODO CONTINUATION]
|
|
16
|
+
[SYSTEM REMINDER - TODO CONTINUATION & VERIFICATION]
|
|
15
17
|
|
|
16
18
|
You have pending todos that are NOT yet completed. You MUST continue working.
|
|
17
19
|
|
|
18
20
|
**Pending Todos:**
|
|
19
21
|
{pending_todos}
|
|
20
22
|
|
|
21
|
-
**
|
|
22
|
-
1. You CANNOT
|
|
23
|
-
2.
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
23
|
+
**CRITICAL RULES:**
|
|
24
|
+
1. You CANNOT mark a todo completed without CONCRETE EVIDENCE
|
|
25
|
+
2. Evidence = file paths with line numbers (e.g., src/auth.ts:45-67) or tool output
|
|
26
|
+
3. Vague claims like "I created the file" will be REJECTED
|
|
27
|
+
4. Each completed todo MUST include: `✅ [Todo] - Evidence: path/to/file.py:123`
|
|
28
|
+
5. If you cannot provide evidence, the todo is NOT complete - keep working
|
|
29
|
+
6. Use Read tool to verify file contents before claiming completion
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
**Example GOOD completion:**
|
|
32
|
+
✅ Create auth validation → Evidence: src/auth.ts:45-67 (validateJWT function implemented)
|
|
33
|
+
|
|
34
|
+
**Example BAD completion (will be REJECTED):**
|
|
35
|
+
✅ Create auth validation → I created the validation logic
|
|
36
|
+
|
|
37
|
+
{verification_failures}
|
|
38
|
+
|
|
39
|
+
CONTINUE WORKING NOW with evidence-backed completions.
|
|
28
40
|
"""
|
|
29
41
|
|
|
30
42
|
|
|
31
|
-
async def todo_continuation_hook(params:
|
|
43
|
+
async def todo_continuation_hook(params: dict[str, Any]) -> dict[str, Any] | None:
|
|
32
44
|
"""
|
|
33
45
|
Pre-model invoke hook that checks for pending todos.
|
|
34
46
|
|
|
35
47
|
If pending todos exist, injects a reminder into the prompt
|
|
36
48
|
forcing the agent to continue working.
|
|
49
|
+
|
|
50
|
+
Also extracts evidence from agent output and verifies claims.
|
|
37
51
|
"""
|
|
38
52
|
prompt = params.get("prompt", "")
|
|
39
53
|
|
|
54
|
+
# Extract pending todos
|
|
40
55
|
pending_todos = _extract_pending_todos(prompt)
|
|
41
56
|
|
|
57
|
+
# Extract verification failures from previous output (if any)
|
|
58
|
+
verification_failures = ""
|
|
59
|
+
skip_verification = params.get("skip_verification", False)
|
|
60
|
+
|
|
61
|
+
if not skip_verification:
|
|
62
|
+
# Check if there's recent output to verify
|
|
63
|
+
# This would come from previous agent turns
|
|
64
|
+
previous_output = params.get("previous_output", "")
|
|
65
|
+
if previous_output:
|
|
66
|
+
verification_failures = _verify_agent_claims(previous_output)
|
|
67
|
+
|
|
42
68
|
if pending_todos:
|
|
43
69
|
logger.info(
|
|
44
70
|
f"[TodoEnforcer] Found {len(pending_todos)} pending todos, injecting continuation reminder"
|
|
45
71
|
)
|
|
46
72
|
|
|
47
73
|
todos_formatted = "\n".join(f"- [ ] {todo}" for todo in pending_todos)
|
|
48
|
-
|
|
74
|
+
|
|
75
|
+
# Format verification failures if any
|
|
76
|
+
failures_text = ""
|
|
77
|
+
if verification_failures:
|
|
78
|
+
failures_text = f"\n\n⚠️ VERIFICATION FAILURES FROM PREVIOUS TURN:\n{verification_failures}\n"
|
|
79
|
+
|
|
80
|
+
reminder = TODO_CONTINUATION_REMINDER.format(
|
|
81
|
+
pending_todos=todos_formatted,
|
|
82
|
+
verification_failures=failures_text
|
|
83
|
+
)
|
|
49
84
|
|
|
50
85
|
modified_prompt = prompt + "\n\n" + reminder
|
|
51
86
|
params["prompt"] = modified_prompt
|
|
@@ -73,3 +108,138 @@ def _extract_pending_todos(prompt: str) -> list:
|
|
|
73
108
|
pass
|
|
74
109
|
|
|
75
110
|
return pending
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _extract_evidence(output: str) -> dict[str, list[str]]:
|
|
114
|
+
"""
|
|
115
|
+
Extract evidence references (file paths, URLs) from agent output.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dict with keys: 'files', 'urls', 'commands'
|
|
119
|
+
"""
|
|
120
|
+
evidence = {
|
|
121
|
+
"files": [],
|
|
122
|
+
"urls": [],
|
|
123
|
+
"commands": []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# File path pattern: src/auth.ts:45 or /path/to/file.py or path/file.js:10-20
|
|
127
|
+
# Matches common file extensions and optional line numbers
|
|
128
|
+
file_pattern = r'(?:^|[\s\(\[])([\w/\._-]+\.(?:py|ts|js|tsx|jsx|go|rs|java|c|cpp|h|hpp|md|json|yaml|yml|toml|sh|rb|php|swift|kt))(?::(\d+)(?:-(\d+))?)?'
|
|
129
|
+
|
|
130
|
+
for match in re.finditer(file_pattern, output, re.MULTILINE):
|
|
131
|
+
file_path = match.group(1)
|
|
132
|
+
line_start = match.group(2)
|
|
133
|
+
line_end = match.group(3)
|
|
134
|
+
|
|
135
|
+
# Build reference string
|
|
136
|
+
if line_start:
|
|
137
|
+
if line_end:
|
|
138
|
+
ref = f"{file_path}:{line_start}-{line_end}"
|
|
139
|
+
else:
|
|
140
|
+
ref = f"{file_path}:{line_start}"
|
|
141
|
+
else:
|
|
142
|
+
ref = file_path
|
|
143
|
+
|
|
144
|
+
evidence["files"].append(ref)
|
|
145
|
+
|
|
146
|
+
# URL pattern
|
|
147
|
+
url_pattern = r'https?://[^\s\)\]>]+'
|
|
148
|
+
evidence["urls"] = re.findall(url_pattern, output)
|
|
149
|
+
|
|
150
|
+
# Command/tool usage pattern (e.g., "Used Read tool", "Ran grep")
|
|
151
|
+
command_pattern = r'(?:Used|Ran|Called|Executed)\s+(\w+(?:\s+\w+)?)\s+(?:tool|command)'
|
|
152
|
+
evidence["commands"] = re.findall(command_pattern, output, re.IGNORECASE)
|
|
153
|
+
|
|
154
|
+
return evidence
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _verify_file_claim(claim: str, file_references: list[str]) -> dict[str, Any]:
|
|
158
|
+
"""
|
|
159
|
+
Verify a completion claim has file evidence.
|
|
160
|
+
|
|
161
|
+
This is a synchronous check - actual file existence verification
|
|
162
|
+
would require async Read tool access (not available in hooks).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dict with 'verified' (bool) and 'reason' (str)
|
|
166
|
+
"""
|
|
167
|
+
# Check if claim has any file references
|
|
168
|
+
if not file_references:
|
|
169
|
+
return {
|
|
170
|
+
"verified": False,
|
|
171
|
+
"reason": "No file paths provided as evidence"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Check for vague language that indicates lack of actual work
|
|
175
|
+
vague_patterns = [
|
|
176
|
+
r'\bI\s+(?:created|made|wrote|added|implemented)\b', # "I created..."
|
|
177
|
+
r'\b(?:should|will|would)\s+(?:create|add|implement)\b', # Future tense
|
|
178
|
+
r'\b(?:basically|essentially|just|simply)\b', # Minimizing language
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
claim_lower = claim.lower()
|
|
182
|
+
vague_count = sum(1 for pattern in vague_patterns if re.search(pattern, claim_lower))
|
|
183
|
+
|
|
184
|
+
if vague_count >= 2:
|
|
185
|
+
return {
|
|
186
|
+
"verified": False,
|
|
187
|
+
"reason": f"Claim uses vague language without concrete evidence. Files mentioned: {', '.join(file_references[:3])}"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# If we have file references and no vague language, consider it verified
|
|
191
|
+
# (Actual file content verification would happen in a post-hook with Read access)
|
|
192
|
+
return {
|
|
193
|
+
"verified": True,
|
|
194
|
+
"reason": f"Evidence provided: {', '.join(file_references[:3])}"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _verify_agent_claims(output: str) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Verify agent claims against actual evidence.
|
|
201
|
+
|
|
202
|
+
Extracts completion claims and checks for concrete evidence.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Formatted string of verification failures (empty if all verified)
|
|
206
|
+
"""
|
|
207
|
+
# Extract evidence from output
|
|
208
|
+
evidence = _extract_evidence(output)
|
|
209
|
+
|
|
210
|
+
# Look for completion claims (✅, "completed", "done", etc.)
|
|
211
|
+
completion_patterns = [
|
|
212
|
+
r'✅\s+(.+?)(?:\n|$)', # Checkmark pattern
|
|
213
|
+
r'(?:Completed|Finished|Done):\s*(.+?)(?:\n|$)', # Explicit completion
|
|
214
|
+
r'"status":\s*"completed".*?"content":\s*"(.+?)"', # JSON todo format
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
claims = []
|
|
218
|
+
for pattern in completion_patterns:
|
|
219
|
+
matches = re.finditer(pattern, output, re.IGNORECASE | re.DOTALL)
|
|
220
|
+
for match in matches:
|
|
221
|
+
claim_text = match.group(1).strip()
|
|
222
|
+
if claim_text:
|
|
223
|
+
claims.append(claim_text)
|
|
224
|
+
|
|
225
|
+
if not claims:
|
|
226
|
+
# No completion claims found, nothing to verify
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
# Verify each claim
|
|
230
|
+
failures = []
|
|
231
|
+
for claim in claims:
|
|
232
|
+
verification = _verify_file_claim(claim, evidence["files"])
|
|
233
|
+
if not verification["verified"]:
|
|
234
|
+
failures.append(f"- {claim[:100]}... → {verification['reason']}")
|
|
235
|
+
|
|
236
|
+
if failures:
|
|
237
|
+
return "\n".join([
|
|
238
|
+
"The following completion claims lack concrete evidence:",
|
|
239
|
+
*failures,
|
|
240
|
+
"",
|
|
241
|
+
"REQUIRED: Provide file paths with line numbers (e.g., src/auth.ts:45-67)",
|
|
242
|
+
"Use the Read tool to verify files exist before claiming completion."
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
return ""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .events import EventType, HookPolicy, PolicyResult, ToolCallEvent
|
|
2
|
+
from ..utils.truncation import truncate_output, TruncationStrategy
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TruncationPolicy(HookPolicy):
|
|
6
|
+
def __init__(self, max_chars: int = 20000):
|
|
7
|
+
self.max_chars = max_chars
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
def event_type(self) -> EventType:
|
|
11
|
+
return EventType.POST_TOOL_CALL
|
|
12
|
+
|
|
13
|
+
async def evaluate(self, event: ToolCallEvent) -> PolicyResult:
|
|
14
|
+
if not event.output or len(event.output) <= self.max_chars:
|
|
15
|
+
return PolicyResult(modified_data=event.output)
|
|
16
|
+
|
|
17
|
+
# Skip truncation for read_file since it handles its own truncation with log-awareness
|
|
18
|
+
if event.tool_name == "read_file":
|
|
19
|
+
return PolicyResult(modified_data=event.output)
|
|
20
|
+
|
|
21
|
+
# Use middle truncation for general tool outputs
|
|
22
|
+
modified = truncate_output(
|
|
23
|
+
event.output,
|
|
24
|
+
limit=self.max_chars,
|
|
25
|
+
strategy=TruncationStrategy.MIDDLE
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return PolicyResult(
|
|
29
|
+
modified_data=modified,
|
|
30
|
+
message=modified, # Message is what gets printed in run_as_native
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
policy = TruncationPolicy()
|
|
36
|
+
policy.run_as_native()
|
|
37
|
+
|
mcp_bridge/hooks/truncator.py
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass, asdict
|
|
6
|
+
import threading
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Approximate costs per 1M tokens (Input/Output)
|
|
12
|
+
MODEL_COSTS = {
|
|
13
|
+
"gemini-3-flash": (0.075, 0.30),
|
|
14
|
+
"gemini-3-pro": (1.25, 5.00),
|
|
15
|
+
"gpt-5.2-codex": (2.50, 10.00), # Estimated based on GPT-4o
|
|
16
|
+
"gpt-4o": (2.50, 10.00),
|
|
17
|
+
"claude-3-5-sonnet": (3.00, 15.00),
|
|
18
|
+
"claude-3-5-haiku": (0.25, 1.25),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CostRecord:
|
|
23
|
+
timestamp: float
|
|
24
|
+
model: str
|
|
25
|
+
input_tokens: int
|
|
26
|
+
output_tokens: int
|
|
27
|
+
cost: float
|
|
28
|
+
agent_type: str
|
|
29
|
+
task_id: str
|
|
30
|
+
session_id: str
|
|
31
|
+
|
|
32
|
+
class CostTracker:
|
|
33
|
+
_instance = None
|
|
34
|
+
_lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.file_path = Path.home() / ".stravinsky" / "usage.jsonl"
|
|
38
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def get_instance(cls):
|
|
42
|
+
if cls._instance is None:
|
|
43
|
+
with cls._lock:
|
|
44
|
+
if cls._instance is None:
|
|
45
|
+
cls._instance = cls()
|
|
46
|
+
return cls._instance
|
|
47
|
+
|
|
48
|
+
def calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
|
|
49
|
+
# Default to Flash pricing if unknown
|
|
50
|
+
input_price, output_price = MODEL_COSTS.get(model, MODEL_COSTS["gemini-3-flash"])
|
|
51
|
+
return (input_tokens / 1_000_000 * input_price) + (output_tokens / 1_000_000 * output_price)
|
|
52
|
+
|
|
53
|
+
def track_usage(self, model: str, input_tokens: int, output_tokens: int, agent_type: str = "unknown", task_id: str = ""):
|
|
54
|
+
cost = self.calculate_cost(model, input_tokens, output_tokens)
|
|
55
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
|
|
56
|
+
|
|
57
|
+
record = CostRecord(
|
|
58
|
+
timestamp=time.time(),
|
|
59
|
+
model=model,
|
|
60
|
+
input_tokens=input_tokens,
|
|
61
|
+
output_tokens=output_tokens,
|
|
62
|
+
cost=cost,
|
|
63
|
+
agent_type=agent_type,
|
|
64
|
+
task_id=task_id,
|
|
65
|
+
session_id=session_id
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(self.file_path, "a") as f:
|
|
70
|
+
f.write(json.dumps(asdict(record)) + "\n")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.error(f"Failed to write usage record: {e}")
|
|
73
|
+
|
|
74
|
+
def get_session_summary(self, session_id: str | None = None) -> dict:
|
|
75
|
+
if session_id is None:
|
|
76
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "default")
|
|
77
|
+
|
|
78
|
+
total_cost = 0.0
|
|
79
|
+
total_tokens = 0
|
|
80
|
+
by_agent = {}
|
|
81
|
+
|
|
82
|
+
if not self.file_path.exists():
|
|
83
|
+
return {"total_cost": 0.0, "total_tokens": 0, "by_agent": {}}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(self.file_path, "r") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(line)
|
|
90
|
+
if data.get("session_id") == session_id:
|
|
91
|
+
cost = data.get("cost", 0.0)
|
|
92
|
+
tokens = data.get("input_tokens", 0) + data.get("output_tokens", 0)
|
|
93
|
+
agent = data.get("agent_type", "unknown")
|
|
94
|
+
|
|
95
|
+
total_cost += cost
|
|
96
|
+
total_tokens += tokens
|
|
97
|
+
|
|
98
|
+
if agent not in by_agent:
|
|
99
|
+
by_agent[agent] = {"cost": 0.0, "tokens": 0}
|
|
100
|
+
by_agent[agent]["cost"] += cost
|
|
101
|
+
by_agent[agent]["tokens"] += tokens
|
|
102
|
+
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
continue
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Failed to read usage records: {e}")
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"total_cost": total_cost,
|
|
110
|
+
"total_tokens": total_tokens,
|
|
111
|
+
"by_agent": by_agent
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def get_cost_tracker():
|
|
115
|
+
return CostTracker.get_instance()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native Search Wrapper - Optional Rust integration for performance.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
import asyncio
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from typing import List, Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Attempt to import the native module
|
|
14
|
+
try:
|
|
15
|
+
import stravinsky_native
|
|
16
|
+
HAS_NATIVE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_NATIVE = False
|
|
19
|
+
logger.debug("stravinsky_native module not found. Falling back to CLI tools.")
|
|
20
|
+
|
|
21
|
+
_executor: Optional[ThreadPoolExecutor] = None
|
|
22
|
+
|
|
23
|
+
def get_executor() -> ThreadPoolExecutor:
|
|
24
|
+
"""Get the singleton thread pool executor."""
|
|
25
|
+
global _executor
|
|
26
|
+
if _executor is None:
|
|
27
|
+
# Limit worker threads to avoid overwhelming the system
|
|
28
|
+
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="native_ffi")
|
|
29
|
+
return _executor
|
|
30
|
+
|
|
31
|
+
async def native_glob_files(pattern: str, directory: str = ".") -> Optional[List[str]]:
|
|
32
|
+
"""
|
|
33
|
+
Find files matching a glob pattern using Rust implementation.
|
|
34
|
+
"""
|
|
35
|
+
if not HAS_NATIVE:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Convert to absolute path for Rust
|
|
40
|
+
abs_dir = os.path.abspath(directory)
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
|
|
43
|
+
# Offload blocking FFI call to thread pool
|
|
44
|
+
return await loop.run_in_executor(
|
|
45
|
+
get_executor(),
|
|
46
|
+
stravinsky_native.glob_files,
|
|
47
|
+
abs_dir,
|
|
48
|
+
pattern
|
|
49
|
+
)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Native glob_files failed: {e}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def native_grep_search(pattern: str, directory: str = ".", case_sensitive: bool = False) -> Optional[List[Dict[str, Any]]]:
|
|
55
|
+
"""
|
|
56
|
+
Fast text search using Rust implementation.
|
|
57
|
+
"""
|
|
58
|
+
if not HAS_NATIVE:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
abs_dir = os.path.abspath(directory)
|
|
63
|
+
loop = asyncio.get_running_loop()
|
|
64
|
+
|
|
65
|
+
return await loop.run_in_executor(
|
|
66
|
+
get_executor(),
|
|
67
|
+
stravinsky_native.grep_search,
|
|
68
|
+
pattern,
|
|
69
|
+
abs_dir,
|
|
70
|
+
case_sensitive
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Native grep_search failed: {e}")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
async def native_chunk_code(content: str, language: str) -> Optional[List[Dict[str, Any]]]:
|
|
77
|
+
"""
|
|
78
|
+
AST-aware code chunking using Rust/tree-sitter.
|
|
79
|
+
"""
|
|
80
|
+
if not HAS_NATIVE:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
loop = asyncio.get_running_loop()
|
|
85
|
+
return await loop.run_in_executor(
|
|
86
|
+
get_executor(),
|
|
87
|
+
stravinsky_native.chunk_code,
|
|
88
|
+
content,
|
|
89
|
+
language
|
|
90
|
+
)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"Native chunk_code failed: {e}")
|
|
93
|
+
return None
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Native Watcher Integration - Rust-based file watching.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Callable
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
class NativeFileWatcher:
|
|
17
|
+
"""
|
|
18
|
+
Python wrapper for the Rust-based stravinsky_watcher binary.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, project_path: str, on_change: Callable[[str, str], None]):
|
|
21
|
+
self.project_path = os.path.abspath(project_path)
|
|
22
|
+
self.on_change = on_change
|
|
23
|
+
self.process: Optional[subprocess.Popen] = None
|
|
24
|
+
self._stop_event = threading.Event()
|
|
25
|
+
self._thread: Optional[threading.Thread] = None
|
|
26
|
+
|
|
27
|
+
def _get_binary_path(self) -> Path:
|
|
28
|
+
"""Find the stravinsky_watcher binary."""
|
|
29
|
+
# Try relative to this file
|
|
30
|
+
root_dir = Path(__file__).parent.parent
|
|
31
|
+
candidates = [
|
|
32
|
+
root_dir / "rust_native" / "target" / "release" / "stravinsky_watcher",
|
|
33
|
+
root_dir / "rust_native" / "target" / "debug" / "stravinsky_watcher",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
for c in candidates:
|
|
37
|
+
if c.exists():
|
|
38
|
+
return c
|
|
39
|
+
|
|
40
|
+
raise FileNotFoundError("stravinsky_watcher binary not found. Build it with cargo first.")
|
|
41
|
+
|
|
42
|
+
def start(self):
|
|
43
|
+
"""Start the native watcher process in a background thread."""
|
|
44
|
+
if self.process:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
binary_path = self._get_binary_path()
|
|
48
|
+
logger.info(f"Starting native watcher: {binary_path} {self.project_path}")
|
|
49
|
+
|
|
50
|
+
self.process = subprocess.Popen(
|
|
51
|
+
[str(binary_path), self.project_path],
|
|
52
|
+
stdout=subprocess.PIPE,
|
|
53
|
+
stderr=subprocess.PIPE,
|
|
54
|
+
text=True,
|
|
55
|
+
bufsize=1
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self._thread = threading.Thread(target=self._read_stdout, daemon=True)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
|
|
61
|
+
def stop(self):
|
|
62
|
+
"""Stop the native watcher process."""
|
|
63
|
+
self._stop_event.set()
|
|
64
|
+
|
|
65
|
+
if self.process:
|
|
66
|
+
logger.info(f"Stopping native watcher process (PID: {self.process.pid})")
|
|
67
|
+
# Try to terminate gracefully first
|
|
68
|
+
self.process.terminate()
|
|
69
|
+
try:
|
|
70
|
+
self.process.wait(timeout=1.0)
|
|
71
|
+
except subprocess.TimeoutExpired:
|
|
72
|
+
logger.warning(f"Native watcher (PID: {self.process.pid}) did not terminate, killing...")
|
|
73
|
+
self.process.kill()
|
|
74
|
+
try:
|
|
75
|
+
self.process.wait(timeout=1.0)
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
logger.error(f"Failed to kill native watcher (PID: {self.process.pid})")
|
|
78
|
+
|
|
79
|
+
# Close streams
|
|
80
|
+
if self.process.stdout:
|
|
81
|
+
self.process.stdout.close()
|
|
82
|
+
if self.process.stderr:
|
|
83
|
+
self.process.stderr.close()
|
|
84
|
+
|
|
85
|
+
self.process = None
|
|
86
|
+
|
|
87
|
+
# Wait for reader thread to exit
|
|
88
|
+
if self._thread and self._thread.is_alive():
|
|
89
|
+
# Don't join with timeout in main thread if it might block,
|
|
90
|
+
# but since we closed stdout, the reader loop should break.
|
|
91
|
+
self._thread.join(timeout=1.0)
|
|
92
|
+
if self._thread.is_alive():
|
|
93
|
+
logger.warning("Native watcher reader thread did not exit cleanly")
|
|
94
|
+
self._thread = None
|
|
95
|
+
|
|
96
|
+
def _read_stdout(self):
|
|
97
|
+
"""Read JSON events from the watcher's stdout."""
|
|
98
|
+
if not self.process or not self.process.stdout:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
for line in self.process.stdout:
|
|
102
|
+
if self._stop_event.is_set():
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
line = line.strip()
|
|
106
|
+
if not line or not line.startswith("{"):
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
event = json.loads(line)
|
|
111
|
+
change_type = event.get("type", "unknown")
|
|
112
|
+
path = event.get("path", "")
|
|
113
|
+
self.on_change(change_type, path)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
logger.error(f"Failed to decode watcher event: {line}")
|
|
116
|
+
|
|
117
|
+
def is_running(self) -> bool:
|
|
118
|
+
return self.process is not None and self.process.poll() is None
|