stravinsky 0.2.7__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.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/cli.py +84 -46
- mcp_bridge/auth/oauth.py +88 -63
- mcp_bridge/hooks/__init__.py +29 -8
- mcp_bridge/hooks/agent_reminder.py +61 -0
- mcp_bridge/hooks/auto_slash_command.py +186 -0
- mcp_bridge/hooks/comment_checker.py +136 -0
- mcp_bridge/hooks/context_monitor.py +58 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
- mcp_bridge/hooks/keyword_detector.py +122 -0
- mcp_bridge/hooks/manager.py +27 -8
- mcp_bridge/hooks/preemptive_compaction.py +157 -0
- mcp_bridge/hooks/session_recovery.py +186 -0
- mcp_bridge/hooks/todo_enforcer.py +75 -0
- mcp_bridge/hooks/truncator.py +1 -1
- mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
- mcp_bridge/native_hooks/truncator.py +1 -1
- mcp_bridge/prompts/delphi.py +3 -2
- mcp_bridge/prompts/dewey.py +105 -21
- mcp_bridge/prompts/stravinsky.py +451 -127
- mcp_bridge/server.py +304 -38
- mcp_bridge/server_tools.py +21 -3
- mcp_bridge/tools/__init__.py +2 -1
- mcp_bridge/tools/agent_manager.py +307 -230
- mcp_bridge/tools/init.py +1 -1
- mcp_bridge/tools/model_invoke.py +534 -52
- mcp_bridge/tools/skill_loader.py +51 -47
- mcp_bridge/tools/task_runner.py +74 -30
- mcp_bridge/tools/templates.py +101 -12
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/METADATA +6 -12
- stravinsky-0.2.38.dist-info/RECORD +57 -0
- stravinsky-0.2.7.dist-info/RECORD +0 -47
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
- {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Empty Message Sanitizer Hook.
|
|
3
|
+
|
|
4
|
+
Cleans up empty/malformed messages:
|
|
5
|
+
- Detects empty content in messages
|
|
6
|
+
- Replaces with placeholder or removes
|
|
7
|
+
- Prevents API errors from empty content
|
|
8
|
+
- Registered as pre_model_invoke hook
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Patterns that indicate effectively empty content
|
|
18
|
+
EMPTY_PATTERNS = [
|
|
19
|
+
r'^\s*$', # Whitespace only
|
|
20
|
+
r'^[\n\r]+$', # Newlines only
|
|
21
|
+
r'^[\t ]+$', # Tabs/spaces only
|
|
22
|
+
r'^\s*null\s*$', # Null string
|
|
23
|
+
r'^\s*undefined\s*$', # Undefined string
|
|
24
|
+
r'^\s*None\s*$', # Python None as string
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Patterns for malformed JSON-like content
|
|
28
|
+
MALFORMED_PATTERNS = [
|
|
29
|
+
r'^\s*\{\s*\}\s*$', # Empty JSON object
|
|
30
|
+
r'^\s*\[\s*\]\s*$', # Empty JSON array
|
|
31
|
+
r'^\s*""\s*$', # Empty quoted string
|
|
32
|
+
r"^\s*''\s*$", # Empty single-quoted string
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Characters that might corrupt the prompt
|
|
36
|
+
DANGEROUS_PATTERNS = [
|
|
37
|
+
r'\x00', # Null byte
|
|
38
|
+
r'[\x01-\x08]', # Control characters
|
|
39
|
+
r'[\x0b\x0c]', # Vertical tab, form feed
|
|
40
|
+
r'[\x0e-\x1f]', # More control characters
|
|
41
|
+
r'\x7f', # DEL character
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
PLACEHOLDER_MESSAGE = "[Content sanitized - empty or malformed input detected]"
|
|
45
|
+
|
|
46
|
+
SANITIZATION_NOTICE = """
|
|
47
|
+
> **[MESSAGE SANITIZATION]**
|
|
48
|
+
> {count} empty or malformed message segment(s) were detected and sanitized.
|
|
49
|
+
> This prevents API errors and ensures proper message processing.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_empty_or_malformed(content: str) -> bool:
|
|
54
|
+
"""
|
|
55
|
+
Check if content is empty or malformed.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
content: The content string to check
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if content is empty or malformed
|
|
62
|
+
"""
|
|
63
|
+
if content is None:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
if not isinstance(content, str):
|
|
67
|
+
# Try to convert to string
|
|
68
|
+
try:
|
|
69
|
+
content = str(content)
|
|
70
|
+
except:
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
# Check empty patterns
|
|
74
|
+
for pattern in EMPTY_PATTERNS:
|
|
75
|
+
if re.match(pattern, content, re.IGNORECASE):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
# Check malformed patterns
|
|
79
|
+
for pattern in MALFORMED_PATTERNS:
|
|
80
|
+
if re.match(pattern, content, re.IGNORECASE):
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def contains_dangerous_characters(content: str) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Check if content contains potentially dangerous control characters.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
content: The content string to check
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if dangerous characters are found
|
|
95
|
+
"""
|
|
96
|
+
if not isinstance(content, str):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
100
|
+
if re.search(pattern, content):
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def sanitize_content(content: str) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Sanitize content by removing dangerous characters.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
content: The content to sanitize
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Sanitized content
|
|
115
|
+
"""
|
|
116
|
+
if not isinstance(content, str):
|
|
117
|
+
try:
|
|
118
|
+
content = str(content)
|
|
119
|
+
except:
|
|
120
|
+
return PLACEHOLDER_MESSAGE
|
|
121
|
+
|
|
122
|
+
# Remove dangerous characters
|
|
123
|
+
sanitized = content
|
|
124
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
125
|
+
sanitized = re.sub(pattern, '', sanitized)
|
|
126
|
+
|
|
127
|
+
# If result is empty after sanitization, use placeholder
|
|
128
|
+
if is_empty_or_malformed(sanitized):
|
|
129
|
+
return PLACEHOLDER_MESSAGE
|
|
130
|
+
|
|
131
|
+
return sanitized
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sanitize_message_blocks(prompt: str) -> tuple[str, int]:
|
|
135
|
+
"""
|
|
136
|
+
Scan prompt for message blocks and sanitize empty ones.
|
|
137
|
+
|
|
138
|
+
This handles common message formats in prompts:
|
|
139
|
+
- user: content
|
|
140
|
+
- assistant: content
|
|
141
|
+
- system: content
|
|
142
|
+
- <role>content</role>
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
prompt: The full prompt text
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Tuple of (sanitized prompt, count of sanitized blocks)
|
|
149
|
+
"""
|
|
150
|
+
sanitized_count = 0
|
|
151
|
+
|
|
152
|
+
# Pattern for role-prefixed messages (user:, assistant:, system:)
|
|
153
|
+
role_pattern = re.compile(
|
|
154
|
+
r'((?:user|assistant|system|human|ai):\s*)([\n\r]+|$)',
|
|
155
|
+
re.IGNORECASE | re.MULTILINE
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def replace_empty_role(match):
|
|
159
|
+
nonlocal sanitized_count
|
|
160
|
+
sanitized_count += 1
|
|
161
|
+
role = match.group(1)
|
|
162
|
+
return f"{role}{PLACEHOLDER_MESSAGE}\n"
|
|
163
|
+
|
|
164
|
+
prompt = role_pattern.sub(replace_empty_role, prompt)
|
|
165
|
+
|
|
166
|
+
# Pattern for XML-style message tags
|
|
167
|
+
xml_pattern = re.compile(
|
|
168
|
+
r'(<(?:user|assistant|system|human|ai)>)\s*(</\1>)',
|
|
169
|
+
re.IGNORECASE
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def replace_empty_xml(match):
|
|
173
|
+
nonlocal sanitized_count
|
|
174
|
+
sanitized_count += 1
|
|
175
|
+
open_tag = match.group(1)
|
|
176
|
+
close_tag = match.group(2)
|
|
177
|
+
return f"{open_tag}{PLACEHOLDER_MESSAGE}{close_tag}"
|
|
178
|
+
|
|
179
|
+
prompt = xml_pattern.sub(replace_empty_xml, prompt)
|
|
180
|
+
|
|
181
|
+
return prompt, sanitized_count
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def empty_message_sanitizer_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
185
|
+
"""
|
|
186
|
+
Pre-model invoke hook that sanitizes empty and malformed message content.
|
|
187
|
+
|
|
188
|
+
Scans the prompt for:
|
|
189
|
+
- Empty message blocks
|
|
190
|
+
- Malformed content patterns
|
|
191
|
+
- Dangerous control characters
|
|
192
|
+
|
|
193
|
+
And sanitizes them to prevent API errors.
|
|
194
|
+
"""
|
|
195
|
+
prompt = params.get("prompt", "")
|
|
196
|
+
|
|
197
|
+
# Skip if already sanitized
|
|
198
|
+
if "[MESSAGE SANITIZATION]" in prompt:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
# Skip if prompt is valid and not empty
|
|
202
|
+
if not prompt or not isinstance(prompt, str):
|
|
203
|
+
logger.warning("[EmptyMessageSanitizer] Empty or invalid prompt detected")
|
|
204
|
+
params["prompt"] = PLACEHOLDER_MESSAGE
|
|
205
|
+
return params
|
|
206
|
+
|
|
207
|
+
modifications_made = False
|
|
208
|
+
sanitized_count = 0
|
|
209
|
+
|
|
210
|
+
# Check for dangerous characters in the entire prompt
|
|
211
|
+
if contains_dangerous_characters(prompt):
|
|
212
|
+
prompt = sanitize_content(prompt)
|
|
213
|
+
modifications_made = True
|
|
214
|
+
sanitized_count += 1
|
|
215
|
+
logger.info("[EmptyMessageSanitizer] Removed dangerous control characters")
|
|
216
|
+
|
|
217
|
+
# Sanitize empty message blocks
|
|
218
|
+
prompt, block_count = sanitize_message_blocks(prompt)
|
|
219
|
+
if block_count > 0:
|
|
220
|
+
sanitized_count += block_count
|
|
221
|
+
modifications_made = True
|
|
222
|
+
logger.info(f"[EmptyMessageSanitizer] Sanitized {block_count} empty message blocks")
|
|
223
|
+
|
|
224
|
+
# Check if the entire prompt is effectively empty
|
|
225
|
+
if is_empty_or_malformed(prompt):
|
|
226
|
+
prompt = PLACEHOLDER_MESSAGE
|
|
227
|
+
sanitized_count += 1
|
|
228
|
+
modifications_made = True
|
|
229
|
+
logger.warning("[EmptyMessageSanitizer] Entire prompt was empty/malformed")
|
|
230
|
+
|
|
231
|
+
if not modifications_made:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
# Add sanitization notice if modifications were made
|
|
235
|
+
notice = SANITIZATION_NOTICE.format(count=sanitized_count)
|
|
236
|
+
params["prompt"] = notice + prompt
|
|
237
|
+
|
|
238
|
+
logger.info(f"[EmptyMessageSanitizer] Applied {sanitized_count} sanitization(s)")
|
|
239
|
+
|
|
240
|
+
return params
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keyword Detector Hook.
|
|
3
|
+
|
|
4
|
+
Detects trigger keywords (ironstar, search, analyze) in user prompts
|
|
5
|
+
and injects corresponding mode activation tags.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
IRONSTAR_MODE = """<ironstar-mode>
|
|
15
|
+
[CODE RED] Maximum precision required. Ultrathink before acting.
|
|
16
|
+
|
|
17
|
+
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
|
18
|
+
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
|
19
|
+
|
|
20
|
+
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
|
21
|
+
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
|
22
|
+
- **Documentation & References**: Use dewey agents via BACKGROUND TASKS for API references, examples, external library docs
|
|
23
|
+
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
|
|
24
|
+
- **High-IQ Reasoning**: Leverage delphi for architecture decisions, code review, strategic planning
|
|
25
|
+
- **Frontend/UI Tasks**: Delegate to frontend-ui-ux-engineer for design and implementation
|
|
26
|
+
|
|
27
|
+
## EXECUTION RULES
|
|
28
|
+
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
|
29
|
+
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
|
30
|
+
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
|
31
|
+
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
|
32
|
+
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
|
33
|
+
|
|
34
|
+
## WORKFLOW
|
|
35
|
+
1. Analyze the request and identify required capabilities
|
|
36
|
+
2. Spawn exploration/dewey agents via background_task in PARALLEL (10+ if needed)
|
|
37
|
+
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
|
38
|
+
4. Execute with continuous verification against original requirements
|
|
39
|
+
|
|
40
|
+
## TDD (if test infrastructure exists)
|
|
41
|
+
|
|
42
|
+
1. Write spec (requirements)
|
|
43
|
+
2. Write tests (failing)
|
|
44
|
+
3. RED: tests fail
|
|
45
|
+
4. Implement minimal code
|
|
46
|
+
5. GREEN: tests pass
|
|
47
|
+
6. Refactor if needed (must stay green)
|
|
48
|
+
7. Next feature, repeat
|
|
49
|
+
|
|
50
|
+
## ZERO TOLERANCE FAILURES
|
|
51
|
+
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
|
52
|
+
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
|
53
|
+
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
|
54
|
+
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
|
55
|
+
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
|
56
|
+
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
|
57
|
+
|
|
58
|
+
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
|
59
|
+
|
|
60
|
+
</ironstar-mode>
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
SEARCH_MODE = """[search-mode]
|
|
66
|
+
MAXIMIZE SEARCH EFFORT. Launch multiple background agents IN PARALLEL:
|
|
67
|
+
- explore agents (codebase patterns, file structures, ast-grep)
|
|
68
|
+
- dewey agents (remote repos, official docs, GitHub examples)
|
|
69
|
+
Plus direct tools: Grep, ripgrep (rg), ast-grep (sg)
|
|
70
|
+
NEVER stop at first result - be exhaustive.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
ANALYZE_MODE = """[analyze-mode]
|
|
74
|
+
ANALYSIS MODE. Gather context before diving deep:
|
|
75
|
+
|
|
76
|
+
CONTEXT GATHERING (parallel):
|
|
77
|
+
- 1-2 explore agents (codebase patterns, implementations)
|
|
78
|
+
- 1-2 dewey agents (if external library involved)
|
|
79
|
+
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
|
80
|
+
|
|
81
|
+
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
|
82
|
+
- Consult delphi for strategic guidance
|
|
83
|
+
|
|
84
|
+
SYNTHESIZE findings before proceeding.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
KEYWORD_PATTERNS = {
|
|
88
|
+
r"\bironstar\b": IRONSTAR_MODE,
|
|
89
|
+
r"\birs\b": IRONSTAR_MODE,
|
|
90
|
+
r"\bultrawork\b": IRONSTAR_MODE,
|
|
91
|
+
r"\bulw\b": IRONSTAR_MODE,
|
|
92
|
+
r"\bsearch\b": SEARCH_MODE,
|
|
93
|
+
r"\banalyze\b": ANALYZE_MODE,
|
|
94
|
+
r"\banalysis\b": ANALYZE_MODE,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def keyword_detector_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
99
|
+
"""
|
|
100
|
+
Pre-model invoke hook that detects keywords and injects mode tags.
|
|
101
|
+
"""
|
|
102
|
+
prompt = params.get("prompt", "")
|
|
103
|
+
prompt_lower = prompt.lower()
|
|
104
|
+
|
|
105
|
+
injections = []
|
|
106
|
+
matched_modes = set()
|
|
107
|
+
|
|
108
|
+
for pattern, mode_tag in KEYWORD_PATTERNS.items():
|
|
109
|
+
if re.search(pattern, prompt_lower):
|
|
110
|
+
mode_id = id(mode_tag)
|
|
111
|
+
if mode_id not in matched_modes:
|
|
112
|
+
matched_modes.add(mode_id)
|
|
113
|
+
injections.append(mode_tag)
|
|
114
|
+
logger.info(f"[KeywordDetector] Matched pattern '{pattern}', injecting mode tag")
|
|
115
|
+
|
|
116
|
+
if injections:
|
|
117
|
+
injection_block = "\n".join(injections)
|
|
118
|
+
modified_prompt = prompt + "\n\n" + injection_block
|
|
119
|
+
params["prompt"] = modified_prompt
|
|
120
|
+
return params
|
|
121
|
+
|
|
122
|
+
return None
|
mcp_bridge/hooks/manager.py
CHANGED
|
@@ -8,16 +8,24 @@ from typing import Any, Callable, Dict, List, Optional, Awaitable
|
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
class HookManager:
|
|
12
13
|
"""
|
|
13
14
|
Manages the registration and execution of hooks.
|
|
14
15
|
"""
|
|
16
|
+
|
|
15
17
|
_instance = None
|
|
16
18
|
|
|
17
19
|
def __init__(self):
|
|
18
|
-
self.pre_tool_call_hooks: List[
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
self.pre_tool_call_hooks: List[
|
|
21
|
+
Callable[[str, Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]
|
|
22
|
+
] = []
|
|
23
|
+
self.post_tool_call_hooks: List[
|
|
24
|
+
Callable[[str, Dict[str, Any], str], Awaitable[Optional[str]]]
|
|
25
|
+
] = []
|
|
26
|
+
self.pre_model_invoke_hooks: List[
|
|
27
|
+
Callable[[Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]
|
|
28
|
+
] = []
|
|
21
29
|
|
|
22
30
|
@classmethod
|
|
23
31
|
def get_instance(cls):
|
|
@@ -25,19 +33,27 @@ class HookManager:
|
|
|
25
33
|
cls._instance = cls()
|
|
26
34
|
return cls._instance
|
|
27
35
|
|
|
28
|
-
def register_pre_tool_call(
|
|
36
|
+
def register_pre_tool_call(
|
|
37
|
+
self, hook: Callable[[str, Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]
|
|
38
|
+
):
|
|
29
39
|
"""Run before a tool is called. Can modify arguments or return early result."""
|
|
30
40
|
self.pre_tool_call_hooks.append(hook)
|
|
31
41
|
|
|
32
|
-
def register_post_tool_call(
|
|
42
|
+
def register_post_tool_call(
|
|
43
|
+
self, hook: Callable[[str, Dict[str, Any], str], Awaitable[Optional[str]]]
|
|
44
|
+
):
|
|
33
45
|
"""Run after a tool call. Can modify or recover from tool output/error."""
|
|
34
46
|
self.post_tool_call_hooks.append(hook)
|
|
35
47
|
|
|
36
|
-
def register_pre_model_invoke(
|
|
48
|
+
def register_pre_model_invoke(
|
|
49
|
+
self, hook: Callable[[Dict[str, Any]], Awaitable[Optional[Dict[str, Any]]]]
|
|
50
|
+
):
|
|
37
51
|
"""Run before model invocation. Can modify prompt or parameters."""
|
|
38
52
|
self.pre_model_invoke_hooks.append(hook)
|
|
39
53
|
|
|
40
|
-
async def execute_pre_tool_call(
|
|
54
|
+
async def execute_pre_tool_call(
|
|
55
|
+
self, tool_name: str, arguments: Dict[str, Any]
|
|
56
|
+
) -> Dict[str, Any]:
|
|
41
57
|
"""Executes all pre-tool call hooks."""
|
|
42
58
|
current_args = arguments
|
|
43
59
|
for hook in self.pre_tool_call_hooks:
|
|
@@ -49,7 +65,9 @@ class HookManager:
|
|
|
49
65
|
logger.error(f"[HookManager] Error in pre_tool_call hook {hook.__name__}: {e}")
|
|
50
66
|
return current_args
|
|
51
67
|
|
|
52
|
-
async def execute_post_tool_call(
|
|
68
|
+
async def execute_post_tool_call(
|
|
69
|
+
self, tool_name: str, arguments: Dict[str, Any], output: str
|
|
70
|
+
) -> str:
|
|
53
71
|
"""Executes all post-tool call hooks."""
|
|
54
72
|
current_output = output
|
|
55
73
|
for hook in self.post_tool_call_hooks:
|
|
@@ -73,5 +91,6 @@ class HookManager:
|
|
|
73
91
|
logger.error(f"[HookManager] Error in pre_model_invoke hook {hook.__name__}: {e}")
|
|
74
92
|
return current_params
|
|
75
93
|
|
|
94
|
+
|
|
76
95
|
def get_hook_manager() -> HookManager:
|
|
77
96
|
return HookManager.get_instance()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Preemptive Context Compaction Hook.
|
|
3
|
+
|
|
4
|
+
Proactively compresses context BEFORE hitting limits by:
|
|
5
|
+
- Tracking estimated token usage
|
|
6
|
+
- Triggering compaction at 70% capacity (not waiting for errors)
|
|
7
|
+
- Using DCP -> Truncate -> Summarize pipeline
|
|
8
|
+
- Registered as pre_model_invoke hook
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Token estimation constants
|
|
18
|
+
CHARS_PER_TOKEN = 4 # Rough estimate for English text
|
|
19
|
+
MAX_CONTEXT_TOKENS = 200000 # Claude's context window
|
|
20
|
+
PREEMPTIVE_THRESHOLD = 0.70 # Trigger at 70% capacity
|
|
21
|
+
WARNING_THRESHOLD = 0.85 # Critical warning at 85%
|
|
22
|
+
|
|
23
|
+
# Calculate character thresholds
|
|
24
|
+
PREEMPTIVE_CHAR_THRESHOLD = int(MAX_CONTEXT_TOKENS * CHARS_PER_TOKEN * PREEMPTIVE_THRESHOLD)
|
|
25
|
+
WARNING_CHAR_THRESHOLD = int(MAX_CONTEXT_TOKENS * CHARS_PER_TOKEN * WARNING_THRESHOLD)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def estimate_tokens(text: str) -> int:
|
|
29
|
+
"""Estimate token count from character count."""
|
|
30
|
+
return len(text) // CHARS_PER_TOKEN
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def calculate_usage_percentage(text: str) -> float:
|
|
34
|
+
"""Calculate context window usage as a percentage."""
|
|
35
|
+
estimated_tokens = estimate_tokens(text)
|
|
36
|
+
return (estimated_tokens / MAX_CONTEXT_TOKENS) * 100
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply_dcp_truncation(text: str, target_reduction: float = 0.3) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Apply DCP (Deferred Context Pruning) truncation strategy.
|
|
42
|
+
|
|
43
|
+
Prioritizes keeping:
|
|
44
|
+
1. System instructions (first ~10%)
|
|
45
|
+
2. Recent context (last ~30%)
|
|
46
|
+
3. Key structural elements in middle
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
text: The full context text
|
|
50
|
+
target_reduction: How much to reduce (0.3 = reduce by 30%)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Truncated text with summary markers
|
|
54
|
+
"""
|
|
55
|
+
lines = text.split('\n')
|
|
56
|
+
total_lines = len(lines)
|
|
57
|
+
|
|
58
|
+
if total_lines < 100:
|
|
59
|
+
# Small context, don't truncate
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
# Calculate segment boundaries
|
|
63
|
+
system_end = int(total_lines * 0.10) # Keep first 10%
|
|
64
|
+
recent_start = int(total_lines * 0.70) # Keep last 30%
|
|
65
|
+
|
|
66
|
+
# Extract segments
|
|
67
|
+
system_segment = lines[:system_end]
|
|
68
|
+
recent_segment = lines[recent_start:]
|
|
69
|
+
middle_segment = lines[system_end:recent_start]
|
|
70
|
+
|
|
71
|
+
# For middle segment, keep key structural elements
|
|
72
|
+
kept_middle = []
|
|
73
|
+
for line in middle_segment:
|
|
74
|
+
# Keep lines with structural importance
|
|
75
|
+
if any([
|
|
76
|
+
line.strip().startswith('##'), # Headers
|
|
77
|
+
line.strip().startswith('def '), # Function definitions
|
|
78
|
+
line.strip().startswith('class '), # Class definitions
|
|
79
|
+
line.strip().startswith('- '), # Bullet points
|
|
80
|
+
'error' in line.lower(), # Errors
|
|
81
|
+
'warning' in line.lower(), # Warnings
|
|
82
|
+
'todo' in line.lower(), # TODOs
|
|
83
|
+
]):
|
|
84
|
+
kept_middle.append(line)
|
|
85
|
+
|
|
86
|
+
# Limit middle to avoid bloat
|
|
87
|
+
max_middle = int(total_lines * (1 - target_reduction) * 0.3)
|
|
88
|
+
kept_middle = kept_middle[:max_middle]
|
|
89
|
+
|
|
90
|
+
# Compose truncated context
|
|
91
|
+
truncation_marker = f"\n[...{len(middle_segment) - len(kept_middle)} lines truncated for context optimization...]\n"
|
|
92
|
+
|
|
93
|
+
result_lines = system_segment + [truncation_marker] + kept_middle + [truncation_marker] + recent_segment
|
|
94
|
+
return '\n'.join(result_lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
PREEMPTIVE_COMPACTION_NOTICE = """
|
|
98
|
+
> **[PREEMPTIVE CONTEXT OPTIMIZATION]**
|
|
99
|
+
> Context usage at {usage:.1f}% - proactively optimizing to maintain performance.
|
|
100
|
+
> The context has been structured for efficiency:
|
|
101
|
+
> - System instructions preserved
|
|
102
|
+
> - Recent interactions kept in full
|
|
103
|
+
> - Historical middle sections summarized
|
|
104
|
+
> - Key structural elements retained
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
CRITICAL_WARNING = """
|
|
108
|
+
> **[CRITICAL - CONTEXT WINDOW AT {usage:.1f}%]**
|
|
109
|
+
> Immediate action recommended:
|
|
110
|
+
> 1. Complete current task and document results in TASK_STATE.md
|
|
111
|
+
> 2. Start a new session for fresh context
|
|
112
|
+
> 3. Reference TASK_STATE.md in new session for continuity
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def preemptive_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
117
|
+
"""
|
|
118
|
+
Pre-model invoke hook that proactively compresses context before hitting limits.
|
|
119
|
+
|
|
120
|
+
Uses a multi-tier strategy:
|
|
121
|
+
- Below 70%: No action
|
|
122
|
+
- 70-85%: Apply DCP truncation with notice
|
|
123
|
+
- Above 85%: Apply aggressive truncation with critical warning
|
|
124
|
+
"""
|
|
125
|
+
prompt = params.get("prompt", "")
|
|
126
|
+
prompt_length = len(prompt)
|
|
127
|
+
|
|
128
|
+
# Skip if already optimized recently
|
|
129
|
+
if "[PREEMPTIVE CONTEXT OPTIMIZATION]" in prompt or "[CRITICAL - CONTEXT WINDOW" in prompt:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
usage = calculate_usage_percentage(prompt)
|
|
133
|
+
|
|
134
|
+
if prompt_length >= WARNING_CHAR_THRESHOLD:
|
|
135
|
+
# Critical level - aggressive truncation
|
|
136
|
+
logger.warning(f"[PreemptiveCompaction] Critical context usage: {usage:.1f}%")
|
|
137
|
+
|
|
138
|
+
truncated = apply_dcp_truncation(prompt, target_reduction=0.4)
|
|
139
|
+
notice = CRITICAL_WARNING.format(usage=usage)
|
|
140
|
+
params["prompt"] = notice + truncated
|
|
141
|
+
|
|
142
|
+
logger.info(f"[PreemptiveCompaction] Applied aggressive truncation: {len(prompt)} -> {len(truncated)} chars")
|
|
143
|
+
return params
|
|
144
|
+
|
|
145
|
+
elif prompt_length >= PREEMPTIVE_CHAR_THRESHOLD:
|
|
146
|
+
# Preemptive level - moderate truncation
|
|
147
|
+
logger.info(f"[PreemptiveCompaction] Preemptive compaction at {usage:.1f}%")
|
|
148
|
+
|
|
149
|
+
truncated = apply_dcp_truncation(prompt, target_reduction=0.3)
|
|
150
|
+
notice = PREEMPTIVE_COMPACTION_NOTICE.format(usage=usage)
|
|
151
|
+
params["prompt"] = notice + truncated
|
|
152
|
+
|
|
153
|
+
logger.info(f"[PreemptiveCompaction] Applied moderate truncation: {len(prompt)} -> {len(truncated)} chars")
|
|
154
|
+
return params
|
|
155
|
+
|
|
156
|
+
# Below threshold, no action needed
|
|
157
|
+
return None
|