stravinsky 0.2.52__py3-none-any.whl → 0.4.18__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_store.py +113 -11
- 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/config/MANIFEST_SCHEMA.md +305 -0
- mcp_bridge/config/README.md +276 -0
- mcp_bridge/config/hook_config.py +249 -0
- mcp_bridge/config/hooks_manifest.json +138 -0
- mcp_bridge/config/rate_limits.py +222 -0
- mcp_bridge/config/skills_manifest.json +128 -0
- mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
- mcp_bridge/hooks/README.md +215 -0
- mcp_bridge/hooks/__init__.py +119 -60
- 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 +8 -0
- mcp_bridge/hooks/notification_hook.py +103 -0
- mcp_bridge/hooks/parallel_execution.py +111 -0
- mcp_bridge/hooks/pre_compact.py +82 -183
- mcp_bridge/hooks/rules_injector.py +507 -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 +267 -0
- mcp_bridge/hooks/truncator.py +21 -17
- mcp_bridge/notifications.py +151 -0
- mcp_bridge/prompts/multimodal.py +24 -3
- mcp_bridge/server.py +214 -49
- mcp_bridge/server_tools.py +445 -0
- mcp_bridge/tools/__init__.py +22 -18
- mcp_bridge/tools/agent_manager.py +220 -32
- mcp_bridge/tools/code_search.py +97 -11
- mcp_bridge/tools/lsp/__init__.py +7 -0
- mcp_bridge/tools/lsp/manager.py +448 -0
- mcp_bridge/tools/lsp/tools.py +637 -150
- mcp_bridge/tools/model_invoke.py +208 -106
- mcp_bridge/tools/query_classifier.py +323 -0
- mcp_bridge/tools/semantic_search.py +3042 -0
- mcp_bridge/tools/templates.py +32 -18
- mcp_bridge/update_manager.py +589 -0
- mcp_bridge/update_manager_pypi.py +299 -0
- stravinsky-0.4.18.dist-info/METADATA +468 -0
- stravinsky-0.4.18.dist-info/RECORD +88 -0
- stravinsky-0.4.18.dist-info/entry_points.txt +5 -0
- mcp_bridge/native_hooks/edit_recovery.py +0 -46
- mcp_bridge/native_hooks/todo_delegation.py +0 -54
- mcp_bridge/native_hooks/truncator.py +0 -23
- stravinsky-0.2.52.dist-info/METADATA +0 -204
- stravinsky-0.2.52.dist-info/RECORD +0 -63
- stravinsky-0.2.52.dist-info/entry_points.txt +0 -3
- /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
- {stravinsky-0.2.52.dist-info → stravinsky-0.4.18.dist-info}/WHEEL +0 -0
|
@@ -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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Notification Hook.
|
|
3
|
+
|
|
4
|
+
Provides OS-level desktop notifications when sessions are idle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Dict, Optional, Set
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Track which sessions have been notified (avoid duplicates)
|
|
15
|
+
_notified_sessions: Set[str] = set()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_notification_command(title: str, message: str, sound: bool = True) -> Optional[list]:
|
|
19
|
+
"""
|
|
20
|
+
Get platform-specific notification command.
|
|
21
|
+
|
|
22
|
+
Returns command as list of args, or None if platform not supported.
|
|
23
|
+
"""
|
|
24
|
+
system = platform.system()
|
|
25
|
+
|
|
26
|
+
if system == "Darwin": # macOS
|
|
27
|
+
# Use osascript for macOS notifications
|
|
28
|
+
script = f'display notification "{message}" with title "{title}"'
|
|
29
|
+
if sound:
|
|
30
|
+
script += ' sound name "Glass"'
|
|
31
|
+
return ["osascript", "-e", script]
|
|
32
|
+
|
|
33
|
+
elif system == "Linux":
|
|
34
|
+
# Use notify-send for Linux
|
|
35
|
+
cmd = ["notify-send", title, message]
|
|
36
|
+
if sound:
|
|
37
|
+
cmd.extend(["--urgency=normal"])
|
|
38
|
+
return cmd
|
|
39
|
+
|
|
40
|
+
elif system == "Windows":
|
|
41
|
+
# Use PowerShell for Windows notifications
|
|
42
|
+
ps_script = f"""
|
|
43
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
44
|
+
[Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
45
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
46
|
+
|
|
47
|
+
$template = @"
|
|
48
|
+
<toast>
|
|
49
|
+
<visual>
|
|
50
|
+
<binding template="ToastGeneric">
|
|
51
|
+
<text>{title}</text>
|
|
52
|
+
<text>{message}</text>
|
|
53
|
+
</binding>
|
|
54
|
+
</visual>
|
|
55
|
+
</toast>
|
|
56
|
+
"@
|
|
57
|
+
|
|
58
|
+
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
59
|
+
$xml.LoadXml($template)
|
|
60
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
|
61
|
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Stravinsky").Show($toast)
|
|
62
|
+
"""
|
|
63
|
+
return ["powershell", "-Command", ps_script]
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def session_notifier_hook(
|
|
69
|
+
session_id: str,
|
|
70
|
+
has_pending_todos: bool,
|
|
71
|
+
idle_seconds: float,
|
|
72
|
+
params: Dict[str, Any]
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Session idle hook that sends desktop notification.
|
|
76
|
+
|
|
77
|
+
Called when session becomes idle with pending work.
|
|
78
|
+
"""
|
|
79
|
+
# Skip if already notified for this session
|
|
80
|
+
if session_id in _notified_sessions:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Skip if no pending work
|
|
84
|
+
if not has_pending_todos:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Skip if idle time is too short (< 5 seconds)
|
|
88
|
+
if idle_seconds < 5.0:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Prepare notification
|
|
92
|
+
title = "Stravinsky Session Idle"
|
|
93
|
+
message = f"Session has pending todos and has been idle for {int(idle_seconds)}s"
|
|
94
|
+
|
|
95
|
+
# Get platform-specific command
|
|
96
|
+
cmd = get_notification_command(title, message, sound=True)
|
|
97
|
+
|
|
98
|
+
if not cmd:
|
|
99
|
+
logger.warning(f"[SessionNotifier] Desktop notifications not supported on {platform.system()}")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Send notification (non-blocking)
|
|
104
|
+
subprocess.Popen(
|
|
105
|
+
cmd,
|
|
106
|
+
stdout=subprocess.DEVNULL,
|
|
107
|
+
stderr=subprocess.DEVNULL,
|
|
108
|
+
start_new_session=True
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Mark as notified
|
|
112
|
+
_notified_sessions.add(session_id)
|
|
113
|
+
logger.info(f"[SessionNotifier] Sent desktop notification for session {session_id}")
|
|
114
|
+
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
logger.warning(f"[SessionNotifier] Notification command not found: {cmd[0]}")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"[SessionNotifier] Failed to send notification: {e}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def clear_notification_state(session_id: str) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Clear notification state for a session (called when session resumes activity).
|
|
124
|
+
"""
|
|
125
|
+
_notified_sessions.discard(session_id)
|