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.

Files changed (52) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/token_refresh.py +130 -0
  3. mcp_bridge/cli/__init__.py +6 -0
  4. mcp_bridge/cli/install_hooks.py +1265 -0
  5. mcp_bridge/cli/session_report.py +585 -0
  6. mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
  7. mcp_bridge/hooks/README.md +215 -0
  8. mcp_bridge/hooks/__init__.py +117 -46
  9. mcp_bridge/hooks/edit_recovery.py +42 -37
  10. mcp_bridge/hooks/git_noninteractive.py +89 -0
  11. mcp_bridge/hooks/keyword_detector.py +30 -0
  12. mcp_bridge/hooks/manager.py +50 -0
  13. mcp_bridge/hooks/notification_hook.py +103 -0
  14. mcp_bridge/hooks/parallel_enforcer.py +127 -0
  15. mcp_bridge/hooks/parallel_execution.py +111 -0
  16. mcp_bridge/hooks/pre_compact.py +123 -0
  17. mcp_bridge/hooks/preemptive_compaction.py +81 -7
  18. mcp_bridge/hooks/rules_injector.py +507 -0
  19. mcp_bridge/hooks/session_idle.py +116 -0
  20. mcp_bridge/hooks/session_notifier.py +125 -0
  21. mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
  22. mcp_bridge/hooks/subagent_stop.py +98 -0
  23. mcp_bridge/hooks/task_validator.py +73 -0
  24. mcp_bridge/hooks/tmux_manager.py +141 -0
  25. mcp_bridge/hooks/todo_continuation.py +90 -0
  26. mcp_bridge/hooks/todo_delegation.py +88 -0
  27. mcp_bridge/hooks/tool_messaging.py +164 -0
  28. mcp_bridge/hooks/truncator.py +21 -17
  29. mcp_bridge/prompts/__init__.py +3 -1
  30. mcp_bridge/prompts/dewey.py +30 -20
  31. mcp_bridge/prompts/explore.py +46 -8
  32. mcp_bridge/prompts/multimodal.py +24 -3
  33. mcp_bridge/prompts/planner.py +222 -0
  34. mcp_bridge/prompts/stravinsky.py +107 -28
  35. mcp_bridge/server.py +76 -10
  36. mcp_bridge/server_tools.py +164 -32
  37. mcp_bridge/tools/agent_manager.py +203 -96
  38. mcp_bridge/tools/background_tasks.py +2 -1
  39. mcp_bridge/tools/code_search.py +81 -9
  40. mcp_bridge/tools/lsp/tools.py +6 -2
  41. mcp_bridge/tools/model_invoke.py +270 -47
  42. mcp_bridge/tools/templates.py +32 -18
  43. stravinsky-0.2.67.dist-info/METADATA +284 -0
  44. stravinsky-0.2.67.dist-info/RECORD +76 -0
  45. stravinsky-0.2.67.dist-info/entry_points.txt +5 -0
  46. mcp_bridge/native_hooks/edit_recovery.py +0 -46
  47. mcp_bridge/native_hooks/truncator.py +0 -23
  48. stravinsky-0.2.40.dist-info/METADATA +0 -204
  49. stravinsky-0.2.40.dist-info/RECORD +0 -57
  50. stravinsky-0.2.40.dist-info/entry_points.txt +0 -3
  51. /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
  52. {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 with notice
123
- - Above 85%: Apply aggressive truncation with critical warning
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 truncation: {len(prompt)} -> {len(truncated)} chars")
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 truncation: {len(prompt)} -> {len(truncated)} chars")
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