stravinsky 0.2.40__py3-none-any.whl → 0.2.67__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/token_refresh.py +130 -0
- mcp_bridge/cli/__init__.py +6 -0
- mcp_bridge/cli/install_hooks.py +1265 -0
- mcp_bridge/cli/session_report.py +585 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
- mcp_bridge/hooks/README.md +215 -0
- mcp_bridge/hooks/__init__.py +117 -46
- mcp_bridge/hooks/edit_recovery.py +42 -37
- mcp_bridge/hooks/git_noninteractive.py +89 -0
- mcp_bridge/hooks/keyword_detector.py +30 -0
- mcp_bridge/hooks/manager.py +50 -0
- mcp_bridge/hooks/notification_hook.py +103 -0
- mcp_bridge/hooks/parallel_enforcer.py +127 -0
- mcp_bridge/hooks/parallel_execution.py +111 -0
- mcp_bridge/hooks/pre_compact.py +123 -0
- mcp_bridge/hooks/preemptive_compaction.py +81 -7
- mcp_bridge/hooks/rules_injector.py +507 -0
- mcp_bridge/hooks/session_idle.py +116 -0
- mcp_bridge/hooks/session_notifier.py +125 -0
- mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
- mcp_bridge/hooks/subagent_stop.py +98 -0
- mcp_bridge/hooks/task_validator.py +73 -0
- mcp_bridge/hooks/tmux_manager.py +141 -0
- mcp_bridge/hooks/todo_continuation.py +90 -0
- mcp_bridge/hooks/todo_delegation.py +88 -0
- mcp_bridge/hooks/tool_messaging.py +164 -0
- mcp_bridge/hooks/truncator.py +21 -17
- mcp_bridge/prompts/__init__.py +3 -1
- mcp_bridge/prompts/dewey.py +30 -20
- mcp_bridge/prompts/explore.py +46 -8
- mcp_bridge/prompts/multimodal.py +24 -3
- mcp_bridge/prompts/planner.py +222 -0
- mcp_bridge/prompts/stravinsky.py +107 -28
- mcp_bridge/server.py +76 -10
- mcp_bridge/server_tools.py +164 -32
- mcp_bridge/tools/agent_manager.py +203 -96
- mcp_bridge/tools/background_tasks.py +2 -1
- mcp_bridge/tools/code_search.py +81 -9
- mcp_bridge/tools/lsp/tools.py +6 -2
- mcp_bridge/tools/model_invoke.py +270 -47
- mcp_bridge/tools/templates.py +32 -18
- stravinsky-0.2.67.dist-info/METADATA +284 -0
- stravinsky-0.2.67.dist-info/RECORD +76 -0
- stravinsky-0.2.67.dist-info/entry_points.txt +5 -0
- mcp_bridge/native_hooks/edit_recovery.py +0 -46
- mcp_bridge/native_hooks/truncator.py +0 -23
- stravinsky-0.2.40.dist-info/METADATA +0 -204
- stravinsky-0.2.40.dist-info/RECORD +0 -57
- stravinsky-0.2.40.dist-info/entry_points.txt +0 -3
- /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
- {stravinsky-0.2.40.dist-info → stravinsky-0.2.67.dist-info}/WHEEL +0 -0
|
@@ -4,7 +4,7 @@ Preemptive Context Compaction Hook.
|
|
|
4
4
|
Proactively compresses context BEFORE hitting limits by:
|
|
5
5
|
- Tracking estimated token usage
|
|
6
6
|
- Triggering compaction at 70% capacity (not waiting for errors)
|
|
7
|
-
- Using DCP -> Truncate -> Summarize pipeline
|
|
7
|
+
- Using DCP -> Truncate -> Summarize pipeline with gemini-3-flash
|
|
8
8
|
- Registered as pre_model_invoke hook
|
|
9
9
|
"""
|
|
10
10
|
|
|
@@ -14,6 +14,9 @@ from typing import Any, Dict, Optional
|
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
|
+
# Flag to prevent recursive summarization calls
|
|
18
|
+
_in_summarization = False
|
|
19
|
+
|
|
17
20
|
# Token estimation constants
|
|
18
21
|
CHARS_PER_TOKEN = 4 # Rough estimate for English text
|
|
19
22
|
MAX_CONTEXT_TOKENS = 200000 # Claude's context window
|
|
@@ -112,6 +115,60 @@ CRITICAL_WARNING = """
|
|
|
112
115
|
> 3. Reference TASK_STATE.md in new session for continuity
|
|
113
116
|
"""
|
|
114
117
|
|
|
118
|
+
SUMMARIZATION_PROMPT = """Summarize the following context concisely while preserving:
|
|
119
|
+
1. Key technical decisions and their rationale
|
|
120
|
+
2. Important code patterns and file paths mentioned
|
|
121
|
+
3. Current task state and pending items
|
|
122
|
+
4. Any errors or warnings that need attention
|
|
123
|
+
|
|
124
|
+
Keep the summary under 2000 characters. Use bullet points for clarity.
|
|
125
|
+
|
|
126
|
+
CONTEXT TO SUMMARIZE:
|
|
127
|
+
{content}"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def summarize_with_gemini(token_store: Any, content: str) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Use gemini-3-flash to summarize context for compaction.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
token_store: Token store for Gemini authentication
|
|
136
|
+
content: The content to summarize
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Summarized content or original if summarization fails
|
|
140
|
+
"""
|
|
141
|
+
global _in_summarization
|
|
142
|
+
|
|
143
|
+
if not token_store:
|
|
144
|
+
logger.warning("[PreemptiveCompaction] No token_store available, skipping summarization")
|
|
145
|
+
return content
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# Import here to avoid circular imports
|
|
149
|
+
from mcp_bridge.tools.model_invoke import invoke_gemini
|
|
150
|
+
|
|
151
|
+
_in_summarization = True
|
|
152
|
+
|
|
153
|
+
prompt = SUMMARIZATION_PROMPT.format(content=content[:50000]) # Limit input size
|
|
154
|
+
|
|
155
|
+
summary = await invoke_gemini(
|
|
156
|
+
token_store=token_store,
|
|
157
|
+
prompt=prompt,
|
|
158
|
+
model="gemini-3-flash",
|
|
159
|
+
max_tokens=2000,
|
|
160
|
+
temperature=0.3,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
logger.info(f"[PreemptiveCompaction] Summarized {len(content)} chars -> {len(summary)} chars")
|
|
164
|
+
return summary
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"[PreemptiveCompaction] Summarization failed: {e}")
|
|
168
|
+
return content # Fall back to original content
|
|
169
|
+
finally:
|
|
170
|
+
_in_summarization = False
|
|
171
|
+
|
|
115
172
|
|
|
116
173
|
async def preemptive_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
117
174
|
"""
|
|
@@ -119,11 +176,18 @@ async def preemptive_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[st
|
|
|
119
176
|
|
|
120
177
|
Uses a multi-tier strategy:
|
|
121
178
|
- Below 70%: No action
|
|
122
|
-
- 70-85%: Apply DCP truncation
|
|
123
|
-
- Above 85%: Apply aggressive truncation
|
|
179
|
+
- 70-85%: Apply DCP truncation + gemini-3-flash summarization
|
|
180
|
+
- Above 85%: Apply aggressive truncation + gemini-3-flash summarization
|
|
124
181
|
"""
|
|
182
|
+
global _in_summarization
|
|
183
|
+
|
|
184
|
+
# Prevent recursive calls (when this hook triggers summarization via gemini)
|
|
185
|
+
if _in_summarization:
|
|
186
|
+
return None
|
|
187
|
+
|
|
125
188
|
prompt = params.get("prompt", "")
|
|
126
189
|
prompt_length = len(prompt)
|
|
190
|
+
token_store = params.get("token_store") # May be None if not provided
|
|
127
191
|
|
|
128
192
|
# Skip if already optimized recently
|
|
129
193
|
if "[PREEMPTIVE CONTEXT OPTIMIZATION]" in prompt or "[CRITICAL - CONTEXT WINDOW" in prompt:
|
|
@@ -132,25 +196,35 @@ async def preemptive_compaction_hook(params: Dict[str, Any]) -> Optional[Dict[st
|
|
|
132
196
|
usage = calculate_usage_percentage(prompt)
|
|
133
197
|
|
|
134
198
|
if prompt_length >= WARNING_CHAR_THRESHOLD:
|
|
135
|
-
# Critical level - aggressive truncation
|
|
199
|
+
# Critical level - aggressive truncation + summarization
|
|
136
200
|
logger.warning(f"[PreemptiveCompaction] Critical context usage: {usage:.1f}%")
|
|
137
201
|
|
|
138
202
|
truncated = apply_dcp_truncation(prompt, target_reduction=0.4)
|
|
203
|
+
|
|
204
|
+
# Use gemini-3-flash to summarize the truncated middle section
|
|
205
|
+
if token_store:
|
|
206
|
+
truncated = await summarize_with_gemini(token_store, truncated)
|
|
207
|
+
|
|
139
208
|
notice = CRITICAL_WARNING.format(usage=usage)
|
|
140
209
|
params["prompt"] = notice + truncated
|
|
141
210
|
|
|
142
|
-
logger.info(f"[PreemptiveCompaction] Applied aggressive
|
|
211
|
+
logger.info(f"[PreemptiveCompaction] Applied aggressive compaction: {len(prompt)} -> {len(truncated)} chars")
|
|
143
212
|
return params
|
|
144
213
|
|
|
145
214
|
elif prompt_length >= PREEMPTIVE_CHAR_THRESHOLD:
|
|
146
|
-
# Preemptive level - moderate truncation
|
|
215
|
+
# Preemptive level - moderate truncation + summarization
|
|
147
216
|
logger.info(f"[PreemptiveCompaction] Preemptive compaction at {usage:.1f}%")
|
|
148
217
|
|
|
149
218
|
truncated = apply_dcp_truncation(prompt, target_reduction=0.3)
|
|
219
|
+
|
|
220
|
+
# Use gemini-3-flash to summarize the truncated content
|
|
221
|
+
if token_store:
|
|
222
|
+
truncated = await summarize_with_gemini(token_store, truncated)
|
|
223
|
+
|
|
150
224
|
notice = PREEMPTIVE_COMPACTION_NOTICE.format(usage=usage)
|
|
151
225
|
params["prompt"] = notice + truncated
|
|
152
226
|
|
|
153
|
-
logger.info(f"[PreemptiveCompaction] Applied moderate
|
|
227
|
+
logger.info(f"[PreemptiveCompaction] Applied moderate compaction: {len(prompt)} -> {len(truncated)} chars")
|
|
154
228
|
return params
|
|
155
229
|
|
|
156
230
|
# Below threshold, no action needed
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rules Injector Hook - Automatic Coding Standards Injection
|
|
3
|
+
|
|
4
|
+
ARCHITECTURE:
|
|
5
|
+
- Tier 2 Pre-Model-Invoke Hook
|
|
6
|
+
- Session-scoped caching with deduplication
|
|
7
|
+
- File path pattern matching via glob
|
|
8
|
+
- Priority-based rule ordering and truncation
|
|
9
|
+
|
|
10
|
+
DISCOVERY:
|
|
11
|
+
1. Searches .claude/rules/ (project-local and user-global)
|
|
12
|
+
2. Parses YAML frontmatter for globs and metadata
|
|
13
|
+
3. Caches discovered rules for session duration
|
|
14
|
+
|
|
15
|
+
MATCHING:
|
|
16
|
+
1. Extracts file paths from prompt context
|
|
17
|
+
2. Matches files against rule glob patterns
|
|
18
|
+
3. Sorts by priority (lower = higher)
|
|
19
|
+
4. Deduplicates via session cache
|
|
20
|
+
|
|
21
|
+
INJECTION:
|
|
22
|
+
1. Formats matched rules as markdown
|
|
23
|
+
2. Prepends to model prompt
|
|
24
|
+
3. Respects token budget (max 4k tokens)
|
|
25
|
+
4. Truncates low-priority rules if needed
|
|
26
|
+
|
|
27
|
+
ERROR HANDLING:
|
|
28
|
+
- Graceful degradation on all errors
|
|
29
|
+
- Never blocks model invocation
|
|
30
|
+
- Logs warnings for malformed rules
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from fnmatch import fnmatch
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict, Optional, Set
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Constants
|
|
43
|
+
MAX_RULES_TOKENS = 4000 # Reserve max 4k tokens for rules
|
|
44
|
+
TOKEN_ESTIMATE_RATIO = 4 # ~4 chars per token (conservative)
|
|
45
|
+
|
|
46
|
+
# Session-scoped caches
|
|
47
|
+
_rules_injection_cache: Dict[str, Set[str]] = {}
|
|
48
|
+
_session_rules_cache: Dict[str, list] = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RuleFile:
|
|
53
|
+
"""Represents a discovered rule file with metadata."""
|
|
54
|
+
name: str
|
|
55
|
+
path: str
|
|
56
|
+
scope: str # "project" or "user"
|
|
57
|
+
globs: tuple[str, ...] # Use tuple instead of list for hashability
|
|
58
|
+
description: str
|
|
59
|
+
priority: int
|
|
60
|
+
body: str
|
|
61
|
+
enabled: bool = True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
65
|
+
"""
|
|
66
|
+
Parse YAML frontmatter from markdown content.
|
|
67
|
+
|
|
68
|
+
Supports:
|
|
69
|
+
- Simple key: value
|
|
70
|
+
- Arrays: globs: ["*.py", "*.js"]
|
|
71
|
+
- Multi-line arrays:
|
|
72
|
+
globs:
|
|
73
|
+
- "*.py"
|
|
74
|
+
- "*.js"
|
|
75
|
+
"""
|
|
76
|
+
if not content.startswith("---"):
|
|
77
|
+
return {}, content
|
|
78
|
+
|
|
79
|
+
end_match = content.find("---", 3)
|
|
80
|
+
if end_match == -1:
|
|
81
|
+
return {}, content
|
|
82
|
+
|
|
83
|
+
frontmatter_block = content[3:end_match].strip()
|
|
84
|
+
body = content[end_match + 3:].strip()
|
|
85
|
+
|
|
86
|
+
metadata = {}
|
|
87
|
+
current_key = None
|
|
88
|
+
array_buffer = []
|
|
89
|
+
|
|
90
|
+
for line in frontmatter_block.split("\n"):
|
|
91
|
+
line = line.rstrip()
|
|
92
|
+
|
|
93
|
+
# Array item: " - value"
|
|
94
|
+
if line.strip().startswith("-") and current_key:
|
|
95
|
+
value = line.strip()[1:].strip().strip('"').strip("'")
|
|
96
|
+
array_buffer.append(value)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Key-value pair
|
|
100
|
+
if ":" in line:
|
|
101
|
+
# Flush previous array
|
|
102
|
+
if current_key and array_buffer:
|
|
103
|
+
metadata[current_key] = array_buffer
|
|
104
|
+
array_buffer = []
|
|
105
|
+
|
|
106
|
+
key, _, value = line.partition(":")
|
|
107
|
+
key = key.strip()
|
|
108
|
+
value = value.strip()
|
|
109
|
+
|
|
110
|
+
# Inline array: globs: ["*.py", "*.js"]
|
|
111
|
+
if value.startswith("[") and value.endswith("]"):
|
|
112
|
+
values = value[1:-1].split(",")
|
|
113
|
+
metadata[key] = [v.strip().strip('"').strip("'") for v in values if v.strip()]
|
|
114
|
+
current_key = None
|
|
115
|
+
# Empty value (likely multi-line array follows)
|
|
116
|
+
elif not value:
|
|
117
|
+
current_key = key
|
|
118
|
+
array_buffer = []
|
|
119
|
+
# Simple value
|
|
120
|
+
else:
|
|
121
|
+
metadata[key] = value.strip('"').strip("'")
|
|
122
|
+
current_key = None
|
|
123
|
+
|
|
124
|
+
# Flush final array
|
|
125
|
+
if current_key and array_buffer:
|
|
126
|
+
metadata[current_key] = array_buffer
|
|
127
|
+
|
|
128
|
+
return metadata, body
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def discover_rules(project_path: Optional[str] = None) -> list[RuleFile]:
|
|
132
|
+
"""
|
|
133
|
+
Discover all rule files from .claude/rules/ directories.
|
|
134
|
+
|
|
135
|
+
Search order:
|
|
136
|
+
1. Project-local: {project}/.claude/rules/**/*.md (highest priority)
|
|
137
|
+
2. User-global: ~/.claude/rules/**/*.md (fallback)
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of RuleFile objects sorted by priority (lower = higher)
|
|
141
|
+
"""
|
|
142
|
+
rules = []
|
|
143
|
+
search_paths = []
|
|
144
|
+
|
|
145
|
+
# Project-local rules (highest priority)
|
|
146
|
+
if project_path:
|
|
147
|
+
project = Path(project_path)
|
|
148
|
+
else:
|
|
149
|
+
project = Path.cwd()
|
|
150
|
+
|
|
151
|
+
project_rules = project / ".claude" / "rules"
|
|
152
|
+
if project_rules.exists() and project_rules.is_dir():
|
|
153
|
+
search_paths.append(("project", project_rules))
|
|
154
|
+
|
|
155
|
+
# User-global rules (fallback)
|
|
156
|
+
user_rules = Path.home() / ".claude" / "rules"
|
|
157
|
+
if user_rules.exists() and user_rules.is_dir():
|
|
158
|
+
search_paths.append(("user", user_rules))
|
|
159
|
+
|
|
160
|
+
for scope, rules_dir in search_paths:
|
|
161
|
+
try:
|
|
162
|
+
for md_file in rules_dir.glob("**/*.md"):
|
|
163
|
+
try:
|
|
164
|
+
content = md_file.read_text(encoding='utf-8')
|
|
165
|
+
metadata, body = parse_frontmatter(content)
|
|
166
|
+
|
|
167
|
+
# Parse globs from frontmatter
|
|
168
|
+
globs_raw = metadata.get("globs", [])
|
|
169
|
+
|
|
170
|
+
# Handle both string and list formats
|
|
171
|
+
if isinstance(globs_raw, str):
|
|
172
|
+
globs = [g.strip() for g in globs_raw.split(",") if g.strip()]
|
|
173
|
+
elif isinstance(globs_raw, list):
|
|
174
|
+
globs = [str(g).strip() for g in globs_raw if g]
|
|
175
|
+
else:
|
|
176
|
+
globs = []
|
|
177
|
+
|
|
178
|
+
if not globs:
|
|
179
|
+
logger.warning(f"[RulesInjector] Skipping {md_file.name}: no globs defined")
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Parse enabled flag (default: true)
|
|
183
|
+
enabled = metadata.get("enabled", "true")
|
|
184
|
+
if isinstance(enabled, str):
|
|
185
|
+
enabled = enabled.lower() in ("true", "yes", "1")
|
|
186
|
+
|
|
187
|
+
rules.append(RuleFile(
|
|
188
|
+
name=md_file.stem,
|
|
189
|
+
path=str(md_file),
|
|
190
|
+
scope=scope,
|
|
191
|
+
globs=tuple(globs), # Convert to tuple for hashability
|
|
192
|
+
description=metadata.get("description", ""),
|
|
193
|
+
priority=int(metadata.get("priority", "100")),
|
|
194
|
+
body=body.strip(),
|
|
195
|
+
enabled=enabled
|
|
196
|
+
))
|
|
197
|
+
|
|
198
|
+
logger.debug(f"[RulesInjector] Discovered rule: {md_file.stem} ({len(globs)} patterns)")
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.warning(f"[RulesInjector] Failed to parse {md_file}: {e}")
|
|
202
|
+
continue
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"[RulesInjector] Failed to scan {rules_dir}: {e}")
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Sort by priority (lower numbers = higher priority)
|
|
208
|
+
rules.sort(key=lambda r: r.priority)
|
|
209
|
+
|
|
210
|
+
logger.info(f"[RulesInjector] Discovered {len(rules)} rules from {len(search_paths)} locations")
|
|
211
|
+
|
|
212
|
+
return rules
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def extract_file_paths_from_context(params: Dict[str, Any]) -> Set[str]:
|
|
216
|
+
"""
|
|
217
|
+
Extract file paths from prompt context.
|
|
218
|
+
|
|
219
|
+
Looks for:
|
|
220
|
+
1. Tool references: "Read /path/to/file.py"
|
|
221
|
+
2. File path patterns: file_path: "/path/to/file.py"
|
|
222
|
+
3. Explicit file mentions
|
|
223
|
+
"""
|
|
224
|
+
paths = set()
|
|
225
|
+
prompt = params.get("prompt", "")
|
|
226
|
+
|
|
227
|
+
# Common tool reference patterns
|
|
228
|
+
file_patterns = [
|
|
229
|
+
r'(?:Read|Edit|Write|MultiEdit)[\s:]+([^\s]+\.(?:py|ts|js|tsx|jsx|md|json|yaml|yml|toml|rs|go|java|cpp|c|h|hpp))',
|
|
230
|
+
r'file_path["\']?\s*[:=]\s*["\']?([^"\'}\s]+)',
|
|
231
|
+
r'path["\']?\s*[:=]\s*["\']?([^"\'}\s]+\.(?:py|ts|js|tsx|jsx|rs|go))',
|
|
232
|
+
r'([/~][^\s]+\.(?:py|ts|js|tsx|jsx|md|json|yaml|yml|toml|rs|go|java|cpp|c|h|hpp))',
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
for pattern in file_patterns:
|
|
236
|
+
for match in re.finditer(pattern, prompt):
|
|
237
|
+
path_str = match.group(1).strip()
|
|
238
|
+
path = Path(path_str)
|
|
239
|
+
|
|
240
|
+
# Expand home directory
|
|
241
|
+
if path_str.startswith("~"):
|
|
242
|
+
path = path.expanduser()
|
|
243
|
+
|
|
244
|
+
# Check if file exists
|
|
245
|
+
if path.exists() and path.is_file():
|
|
246
|
+
paths.add(str(path.absolute()))
|
|
247
|
+
|
|
248
|
+
return paths
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def match_rules_to_files(rules: list[RuleFile], file_paths: Set[str], project_path: str) -> list[RuleFile]:
|
|
252
|
+
"""
|
|
253
|
+
Match discovered rules to active file paths using glob patterns.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
rules: List of discovered RuleFile objects
|
|
257
|
+
file_paths: Set of absolute file paths from context
|
|
258
|
+
project_path: Project root for relative path resolution
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of matched RuleFile objects (deduplicated, priority-sorted)
|
|
262
|
+
"""
|
|
263
|
+
matched = set()
|
|
264
|
+
project = Path(project_path)
|
|
265
|
+
|
|
266
|
+
for file_path in file_paths:
|
|
267
|
+
path = Path(file_path)
|
|
268
|
+
|
|
269
|
+
# Try both absolute and relative matching
|
|
270
|
+
try:
|
|
271
|
+
relative_path = str(path.relative_to(project)) if path.is_relative_to(project) else None
|
|
272
|
+
except (ValueError, TypeError):
|
|
273
|
+
relative_path = None
|
|
274
|
+
|
|
275
|
+
for rule in rules:
|
|
276
|
+
# Skip disabled rules
|
|
277
|
+
if not rule.enabled:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# Check each glob pattern
|
|
281
|
+
for glob_pattern in rule.globs:
|
|
282
|
+
matched_this_pattern = False
|
|
283
|
+
|
|
284
|
+
# Match absolute path
|
|
285
|
+
if fnmatch(str(path), glob_pattern):
|
|
286
|
+
matched_this_pattern = True
|
|
287
|
+
|
|
288
|
+
# Match relative path
|
|
289
|
+
elif relative_path and fnmatch(relative_path, glob_pattern):
|
|
290
|
+
matched_this_pattern = True
|
|
291
|
+
|
|
292
|
+
# Match filename only (for patterns like "*.py")
|
|
293
|
+
elif fnmatch(path.name, glob_pattern):
|
|
294
|
+
matched_this_pattern = True
|
|
295
|
+
|
|
296
|
+
if matched_this_pattern:
|
|
297
|
+
matched.add(rule)
|
|
298
|
+
logger.debug(f"[RulesInjector] Matched rule '{rule.name}' to {path.name}")
|
|
299
|
+
break # One match per rule is enough
|
|
300
|
+
|
|
301
|
+
# Return sorted by priority
|
|
302
|
+
return sorted(matched, key=lambda r: r.priority)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def truncate_rules_by_priority(matched_rules: list[RuleFile], max_tokens: int = MAX_RULES_TOKENS) -> list[RuleFile]:
|
|
306
|
+
"""
|
|
307
|
+
Truncate rules to fit within token budget, preserving high-priority rules.
|
|
308
|
+
|
|
309
|
+
Strategy:
|
|
310
|
+
1. Always include highest priority rules
|
|
311
|
+
2. Truncate individual rule bodies if needed
|
|
312
|
+
3. Drop lowest priority rules if still over budget
|
|
313
|
+
"""
|
|
314
|
+
max_chars = max_tokens * TOKEN_ESTIMATE_RATIO
|
|
315
|
+
|
|
316
|
+
included = []
|
|
317
|
+
total_chars = 0
|
|
318
|
+
|
|
319
|
+
for rule in sorted(matched_rules, key=lambda r: r.priority):
|
|
320
|
+
rule_chars = len(rule.body) + 100 # +100 for formatting overhead
|
|
321
|
+
|
|
322
|
+
if total_chars + rule_chars <= max_chars:
|
|
323
|
+
# Fits completely
|
|
324
|
+
included.append(rule)
|
|
325
|
+
total_chars += rule_chars
|
|
326
|
+
else:
|
|
327
|
+
# Try truncating the rule body
|
|
328
|
+
remaining_budget = max_chars - total_chars
|
|
329
|
+
if remaining_budget > 500: # Minimum useful rule size
|
|
330
|
+
truncated_body = rule.body[:remaining_budget - 200] + "\n...[TRUNCATED]"
|
|
331
|
+
included.append(RuleFile(
|
|
332
|
+
name=rule.name,
|
|
333
|
+
path=rule.path,
|
|
334
|
+
scope=rule.scope,
|
|
335
|
+
globs=rule.globs, # Already a tuple
|
|
336
|
+
description=rule.description,
|
|
337
|
+
priority=rule.priority,
|
|
338
|
+
body=truncated_body,
|
|
339
|
+
enabled=rule.enabled
|
|
340
|
+
))
|
|
341
|
+
total_chars += remaining_budget
|
|
342
|
+
# No more budget - stop including rules
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
if len(included) < len(matched_rules):
|
|
346
|
+
logger.warning(f"[RulesInjector] Truncated {len(matched_rules) - len(included)} rules due to token budget")
|
|
347
|
+
|
|
348
|
+
return included
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def format_rules_injection(rules: list[RuleFile]) -> str:
|
|
352
|
+
"""
|
|
353
|
+
Format matched rules for injection into prompt.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Formatted markdown block with all applicable rules
|
|
357
|
+
"""
|
|
358
|
+
if not rules:
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
header = """
|
|
362
|
+
> **[AUTO-RULES INJECTION]**
|
|
363
|
+
> The following coding standards/rules apply to files in this context:
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
rules_blocks = []
|
|
367
|
+
for rule in rules:
|
|
368
|
+
globs_display = ', '.join(rule.globs[:3])
|
|
369
|
+
if len(rule.globs) > 3:
|
|
370
|
+
globs_display += "..."
|
|
371
|
+
|
|
372
|
+
block = f"""
|
|
373
|
+
---
|
|
374
|
+
### Rule: {rule.name}
|
|
375
|
+
**Scope**: {rule.scope} | **Files**: {globs_display}
|
|
376
|
+
**Description**: {rule.description}
|
|
377
|
+
|
|
378
|
+
{rule.body}
|
|
379
|
+
---
|
|
380
|
+
"""
|
|
381
|
+
rules_blocks.append(block)
|
|
382
|
+
|
|
383
|
+
return header + "\n".join(rules_blocks)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def get_session_cache_key(session_id: str, file_paths: Set[str]) -> str:
|
|
387
|
+
"""Generate cache key for session + file combination."""
|
|
388
|
+
sorted_paths = "|".join(sorted(file_paths))
|
|
389
|
+
return f"{session_id}:{sorted_paths}"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def is_already_injected(session_id: str, file_paths: Set[str], rule_names: list[str]) -> bool:
|
|
393
|
+
"""
|
|
394
|
+
Check if rules have already been injected for this session + file combination.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
True if ALL rules have been injected before
|
|
398
|
+
"""
|
|
399
|
+
cache_key = get_session_cache_key(session_id, file_paths)
|
|
400
|
+
|
|
401
|
+
if cache_key not in _rules_injection_cache:
|
|
402
|
+
_rules_injection_cache[cache_key] = set()
|
|
403
|
+
|
|
404
|
+
injected = _rules_injection_cache[cache_key]
|
|
405
|
+
new_rules = set(rule_names) - injected
|
|
406
|
+
|
|
407
|
+
if new_rules:
|
|
408
|
+
# Mark as injected
|
|
409
|
+
_rules_injection_cache[cache_key].update(new_rules)
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
return True # All rules already injected
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def clear_session_cache(session_id: str):
|
|
416
|
+
"""Clear all cached injections for a session (call on session_compact or session_end)."""
|
|
417
|
+
keys_to_remove = [k for k in _rules_injection_cache if k.startswith(f"{session_id}:")]
|
|
418
|
+
for key in keys_to_remove:
|
|
419
|
+
del _rules_injection_cache[key]
|
|
420
|
+
|
|
421
|
+
# Also clear rules cache
|
|
422
|
+
keys_to_remove = [k for k in _session_rules_cache if k.startswith(f"{session_id}:")]
|
|
423
|
+
for key in keys_to_remove:
|
|
424
|
+
del _session_rules_cache[key]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def get_cached_rules(session_id: str, project_path: str) -> list[RuleFile]:
|
|
428
|
+
"""Get or discover rules for session (cached)."""
|
|
429
|
+
cache_key = f"{session_id}:{project_path}"
|
|
430
|
+
|
|
431
|
+
if cache_key not in _session_rules_cache:
|
|
432
|
+
_session_rules_cache[cache_key] = discover_rules(project_path)
|
|
433
|
+
|
|
434
|
+
return _session_rules_cache[cache_key]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def get_project_path_from_prompt(prompt: str) -> Optional[str]:
|
|
438
|
+
"""Extract project path from prompt if available."""
|
|
439
|
+
# Look for common working directory indicators
|
|
440
|
+
patterns = [
|
|
441
|
+
r'Working directory:\s*([^\n]+)',
|
|
442
|
+
r'Project path:\s*([^\n]+)',
|
|
443
|
+
r'cwd:\s*([^\n]+)',
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
for pattern in patterns:
|
|
447
|
+
match = re.search(pattern, prompt)
|
|
448
|
+
if match:
|
|
449
|
+
return match.group(1).strip()
|
|
450
|
+
|
|
451
|
+
# Default to current working directory
|
|
452
|
+
return str(Path.cwd())
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
async def rules_injector_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
456
|
+
"""
|
|
457
|
+
Pre-model-invoke hook for automatic rules injection.
|
|
458
|
+
|
|
459
|
+
Gracefully degrades on errors - never blocks model invocation.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
# 1. Extract session ID
|
|
463
|
+
session_id = params.get("session_id", "unknown")
|
|
464
|
+
|
|
465
|
+
# 2. Extract file paths from context
|
|
466
|
+
file_paths = extract_file_paths_from_context(params)
|
|
467
|
+
|
|
468
|
+
if not file_paths:
|
|
469
|
+
# No file context - skip injection
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
# 3. Get project path and discover rules
|
|
473
|
+
project_path = get_project_path_from_prompt(params.get("prompt", ""))
|
|
474
|
+
rules = get_cached_rules(session_id, project_path)
|
|
475
|
+
|
|
476
|
+
if not rules:
|
|
477
|
+
# No rules defined - skip
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
# 4. Match rules to files
|
|
481
|
+
matched = match_rules_to_files(rules, file_paths, project_path)
|
|
482
|
+
|
|
483
|
+
if not matched:
|
|
484
|
+
# No matching rules - skip
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
# 5. Check deduplication cache
|
|
488
|
+
rule_names = [r.name for r in matched]
|
|
489
|
+
if is_already_injected(session_id, file_paths, rule_names):
|
|
490
|
+
logger.debug(f"[RulesInjector] Rules already injected for session {session_id}")
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
# 6. Truncate if needed
|
|
494
|
+
truncated = truncate_rules_by_priority(matched)
|
|
495
|
+
|
|
496
|
+
# 7. Format and inject
|
|
497
|
+
injection = format_rules_injection(truncated)
|
|
498
|
+
modified_params = params.copy()
|
|
499
|
+
modified_params["prompt"] = injection + "\n\n" + params.get("prompt", "")
|
|
500
|
+
|
|
501
|
+
logger.info(f"[RulesInjector] Injected {len(truncated)} rules for {len(file_paths)} files")
|
|
502
|
+
return modified_params
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
# Log error but DON'T block the model invocation
|
|
506
|
+
logger.error(f"[RulesInjector] Hook failed: {e}", exc_info=True)
|
|
507
|
+
return None # Continue without rule injection
|