runtime-memory 3.0.0__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.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,646 @@
1
+ """Runtime Memory Plugin Module.
2
+
3
+ Claude Code 2.1.1+ integration utilities for the Runtime Memory plugin.
4
+
5
+ This module provides:
6
+ - HookContext: Environment variable handling for native hooks
7
+ - ContextFormatter: Memory formatting for context injection
8
+ - SkillTriggers: Trigger detection for Agent Skills
9
+ - SessionManager: Native Claude Code session ID integration
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any, Optional
19
+
20
+ if TYPE_CHECKING:
21
+ from runtime_memory.core.models import Memory
22
+
23
+
24
+ @dataclass
25
+ class HookContext:
26
+ """Context available during hook execution.
27
+
28
+ Captures environment variables set by Claude Code during hook execution:
29
+ - $CLAUDE_SESSION_ID: The current session identifier
30
+ - $PWD: Current working directory (project path)
31
+ - $TOOL_NAME: Name of the tool being used (for PostToolUse)
32
+ - $TOOL_INPUT_FILE_PATH: File path from tool input (for Write/Edit hooks)
33
+ """
34
+
35
+ session_id: Optional[str] = None
36
+ """Claude Code session ID from $CLAUDE_SESSION_ID."""
37
+
38
+ project_path: str = ""
39
+ """Current working directory from $PWD."""
40
+
41
+ tool_name: Optional[str] = None
42
+ """Tool name from $TOOL_NAME (PostToolUse hook)."""
43
+
44
+ tool_input: Optional[dict[str, Any]] = None
45
+ """Parsed tool input (if available)."""
46
+
47
+ file_path: Optional[str] = None
48
+ """File path from $TOOL_INPUT_FILE_PATH (Write/Edit hooks)."""
49
+
50
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
51
+ """When the hook was triggered."""
52
+
53
+ @classmethod
54
+ def from_environment(cls) -> HookContext:
55
+ """Create hook context from environment variables.
56
+
57
+ Returns:
58
+ HookContext populated from current environment.
59
+ """
60
+ return cls(
61
+ session_id=os.environ.get("CLAUDE_SESSION_ID"),
62
+ project_path=os.environ.get("PWD", str(Path.cwd())),
63
+ tool_name=os.environ.get("TOOL_NAME"),
64
+ tool_input=None, # Would need JSON parsing from env
65
+ file_path=os.environ.get("TOOL_INPUT_FILE_PATH"),
66
+ )
67
+
68
+ @property
69
+ def has_session(self) -> bool:
70
+ """Check if a session ID is available."""
71
+ return self.session_id is not None and len(self.session_id) > 0
72
+
73
+ @property
74
+ def project_name(self) -> str:
75
+ """Extract project name from path."""
76
+ if self.project_path:
77
+ return Path(self.project_path).name
78
+ return "unknown"
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ """Convert to dictionary representation."""
82
+ return {
83
+ "session_id": self.session_id,
84
+ "project_path": self.project_path,
85
+ "tool_name": self.tool_name,
86
+ "tool_input": self.tool_input,
87
+ "file_path": self.file_path,
88
+ "timestamp": self.timestamp.isoformat(),
89
+ }
90
+
91
+
92
+ class ContextFormatter:
93
+ """Format memories for context injection.
94
+
95
+ Provides different formatting styles for injecting memories
96
+ into Claude Code's context window.
97
+ """
98
+
99
+ STYLES = ("brief", "detailed", "structured", "markdown")
100
+
101
+ @staticmethod
102
+ def format_for_injection(
103
+ memories: list[Memory],
104
+ style: str = "brief",
105
+ max_memories: int = 20,
106
+ ) -> str:
107
+ """Format memories for context window injection.
108
+
109
+ Args:
110
+ memories: List of Memory objects to format.
111
+ style: Formatting style - "brief", "detailed", "structured", or "markdown".
112
+ max_memories: Maximum number of memories to include.
113
+
114
+ Returns:
115
+ Formatted string for context injection.
116
+ """
117
+ if not memories:
118
+ return ""
119
+
120
+ memories = memories[:max_memories]
121
+
122
+ if style == "brief":
123
+ return ContextFormatter._format_brief(memories)
124
+ elif style == "detailed":
125
+ return ContextFormatter._format_detailed(memories)
126
+ elif style == "structured":
127
+ return ContextFormatter._format_structured(memories)
128
+ elif style == "markdown":
129
+ return ContextFormatter._format_markdown(memories)
130
+ else:
131
+ return ContextFormatter._format_brief(memories)
132
+
133
+ @staticmethod
134
+ def _format_brief(memories: list[Memory]) -> str:
135
+ """Brief format: category + content on single lines."""
136
+ lines = ["# Memory Context", ""]
137
+ for m in memories:
138
+ category = m.category.value if hasattr(m.category, "value") else str(m.category)
139
+ lines.append(f"- [{category}] {m.content}")
140
+ return "\n".join(lines)
141
+
142
+ @staticmethod
143
+ def _format_detailed(memories: list[Memory]) -> str:
144
+ """Detailed format: includes scores and usage stats."""
145
+ lines = ["# Memory Context (detailed)", ""]
146
+ for m in memories:
147
+ category = m.category.value if hasattr(m.category, "value") else str(m.category)
148
+ lines.append(f"## [{m.id[:8]}] {category.upper()}")
149
+ lines.append(f"{m.content}")
150
+ lines.append(f"Score: {m.outcome_score:.2f} | Used: {m.use_count}x | Confidence: {m.confidence:.1f}")
151
+ lines.append("")
152
+ return "\n".join(lines)
153
+
154
+ @staticmethod
155
+ def _format_structured(memories: list[Memory]) -> str:
156
+ """Structured format: grouped by category."""
157
+ by_category: dict[str, list[Memory]] = {}
158
+ for m in memories:
159
+ category = m.category.value if hasattr(m.category, "value") else str(m.category)
160
+ if category not in by_category:
161
+ by_category[category] = []
162
+ by_category[category].append(m)
163
+
164
+ lines = ["# Memory Context", ""]
165
+ for category, mems in sorted(by_category.items()):
166
+ lines.append(f"## {category.replace('_', ' ').title()} ({len(mems)})")
167
+ for m in mems:
168
+ score_indicator = ""
169
+ if m.outcome_score > 0.3:
170
+ score_indicator = " [proven]"
171
+ elif m.outcome_score < -0.2:
172
+ score_indicator = " [questionable]"
173
+ content = m.content[:100] + "..." if len(m.content) > 100 else m.content
174
+ lines.append(f"- [{m.id[:8]}] {content}{score_indicator}")
175
+ lines.append("")
176
+ return "\n".join(lines)
177
+
178
+ @staticmethod
179
+ def _format_markdown(memories: list[Memory]) -> str:
180
+ """Markdown format: full markdown with headers."""
181
+ by_category: dict[str, list[Memory]] = {}
182
+ for m in memories:
183
+ category = m.category.value if hasattr(m.category, "value") else str(m.category)
184
+ if category not in by_category:
185
+ by_category[category] = []
186
+ by_category[category].append(m)
187
+
188
+ lines = ["# Project Knowledge", ""]
189
+
190
+ # Priority order for categories
191
+ priority_order = [
192
+ "architecture", "decision", "convention", "pattern",
193
+ "gotcha", "workaround", "troubleshooting", "command", "preference"
194
+ ]
195
+
196
+ # Sort categories by priority
197
+ sorted_categories = sorted(
198
+ by_category.keys(),
199
+ key=lambda c: priority_order.index(c) if c in priority_order else 99
200
+ )
201
+
202
+ for category in sorted_categories:
203
+ mems = by_category[category]
204
+ # Sort by outcome score descending
205
+ mems.sort(key=lambda m: m.outcome_score, reverse=True)
206
+
207
+ lines.append(f"## {category.replace('_', ' ').title()}")
208
+ lines.append("")
209
+ for m in mems:
210
+ confidence_note = ""
211
+ if m.outcome_score > 0.3:
212
+ confidence_note = " *[high confidence]*"
213
+ elif m.outcome_score < -0.2:
214
+ confidence_note = " *[low confidence]*"
215
+ lines.append(f"- {m.content}{confidence_note}")
216
+ lines.append("")
217
+
218
+ return "\n".join(lines)
219
+
220
+ @staticmethod
221
+ def format_single_memory(
222
+ memory: Memory,
223
+ include_feedback_hint: bool = True,
224
+ ) -> str:
225
+ """Format a single memory for display.
226
+
227
+ Args:
228
+ memory: The memory to format.
229
+ include_feedback_hint: Whether to include feedback command hint.
230
+
231
+ Returns:
232
+ Formatted string.
233
+ """
234
+ category = memory.category.value if hasattr(memory.category, "value") else str(memory.category)
235
+ output = f"[{category}] {memory.content}"
236
+
237
+ if include_feedback_hint:
238
+ output += f"\n> Feedback: `/outcome {memory.id} worked|failed|partial`"
239
+
240
+ return output
241
+
242
+ @staticmethod
243
+ def format_search_results(
244
+ results: list[Any], # SearchResult type
245
+ include_scores: bool = True,
246
+ ) -> str:
247
+ """Format search results for display.
248
+
249
+ Args:
250
+ results: List of SearchResult objects.
251
+ include_scores: Whether to include relevance scores.
252
+
253
+ Returns:
254
+ Formatted string.
255
+ """
256
+ if not results:
257
+ return "No relevant memories found."
258
+
259
+ lines = ["## Relevant Memories", ""]
260
+ for i, r in enumerate(results, 1):
261
+ m = r.memory
262
+ category = m.category.value if hasattr(m.category, "value") else str(m.category)
263
+
264
+ if include_scores:
265
+ lines.append(f"{i}. [{category}] {m.content}")
266
+ lines.append(f" Score: {r.score:.2f} | Outcome: {m.outcome_score:.2f}")
267
+ else:
268
+ lines.append(f"{i}. [{category}] {m.content}")
269
+ lines.append(f" ID: {m.id}")
270
+ lines.append("")
271
+
272
+ lines.append("Use `/outcome <id> worked|failed|partial` to provide feedback.")
273
+ return "\n".join(lines)
274
+
275
+
276
+ class SkillTriggers:
277
+ """Detect when Agent Skills should activate.
278
+
279
+ Agent Skills are automatically loaded by Claude based on conversation
280
+ context. This class provides trigger detection for:
281
+ - memory-retrieval: When user asks about past decisions/conventions
282
+ - outcome-feedback: When user signals success/failure
283
+ - coding-patterns: When user is creating new code
284
+ """
285
+
286
+ # Triggers for memory-retrieval skill
287
+ RETRIEVAL_TRIGGERS = [
288
+ "what did we decide",
289
+ "how do we handle",
290
+ "what's our convention",
291
+ "what's the convention",
292
+ "what's the pattern",
293
+ "what pattern do we",
294
+ "last time we",
295
+ "we discussed",
296
+ "as i mentioned",
297
+ "as we discussed",
298
+ "remember when",
299
+ "what's the approach",
300
+ "how should i",
301
+ "how do i usually",
302
+ "what's our standard",
303
+ "what did we agree",
304
+ "why did we choose",
305
+ "what was the decision",
306
+ ]
307
+
308
+ # Triggers for coding-patterns skill
309
+ PATTERN_TRIGGERS = [
310
+ "create a new",
311
+ "implement a",
312
+ "implement the",
313
+ "write a",
314
+ "add a new",
315
+ "build a",
316
+ "make a new",
317
+ "how should i structure",
318
+ "what's the best way to",
319
+ "how do i create",
320
+ "scaffold a",
321
+ "generate a",
322
+ "set up a",
323
+ "setup a",
324
+ ]
325
+
326
+ # Positive outcome signals
327
+ OUTCOME_POSITIVE = [
328
+ "thanks",
329
+ "thank you",
330
+ "that worked",
331
+ "it worked",
332
+ "works now",
333
+ "perfect",
334
+ "great",
335
+ "excellent",
336
+ "awesome",
337
+ "solved",
338
+ "fixed",
339
+ "that's it",
340
+ "exactly what i needed",
341
+ "you're right",
342
+ ]
343
+
344
+ # Negative outcome signals
345
+ OUTCOME_NEGATIVE = [
346
+ "still not working",
347
+ "doesn't work",
348
+ "didn't work",
349
+ "same error",
350
+ "didn't help",
351
+ "not helpful",
352
+ "nope",
353
+ "wrong",
354
+ "that's wrong",
355
+ "still broken",
356
+ "still failing",
357
+ "no luck",
358
+ "try again",
359
+ "that's not right",
360
+ ]
361
+
362
+ # Partial outcome signals
363
+ OUTCOME_PARTIAL = [
364
+ "kind of",
365
+ "partially",
366
+ "somewhat",
367
+ "helped but",
368
+ "almost",
369
+ "close but",
370
+ "partly",
371
+ "half working",
372
+ "better but",
373
+ ]
374
+
375
+ @classmethod
376
+ def should_retrieve(cls, message: str) -> bool:
377
+ """Check if memory retrieval skill should activate.
378
+
379
+ Args:
380
+ message: User message to analyze.
381
+
382
+ Returns:
383
+ True if retrieval triggers are detected.
384
+ """
385
+ message_lower = message.lower()
386
+ return any(trigger in message_lower for trigger in cls.RETRIEVAL_TRIGGERS)
387
+
388
+ @classmethod
389
+ def should_surface_patterns(cls, message: str) -> bool:
390
+ """Check if coding patterns skill should activate.
391
+
392
+ Args:
393
+ message: User message to analyze.
394
+
395
+ Returns:
396
+ True if pattern triggers are detected.
397
+ """
398
+ message_lower = message.lower()
399
+ return any(trigger in message_lower for trigger in cls.PATTERN_TRIGGERS)
400
+
401
+ @classmethod
402
+ def detect_outcome_signal(cls, message: str) -> Optional[str]:
403
+ """Detect if user is signaling outcome feedback.
404
+
405
+ Args:
406
+ message: User message to analyze.
407
+
408
+ Returns:
409
+ "worked", "failed", "partial", or None if no signal detected.
410
+ """
411
+ message_lower = message.lower()
412
+
413
+ # Check partial first (more specific)
414
+ if any(trigger in message_lower for trigger in cls.OUTCOME_PARTIAL):
415
+ return "partial"
416
+
417
+ # Then positive
418
+ if any(trigger in message_lower for trigger in cls.OUTCOME_POSITIVE):
419
+ return "worked"
420
+
421
+ # Then negative
422
+ if any(trigger in message_lower for trigger in cls.OUTCOME_NEGATIVE):
423
+ return "failed"
424
+
425
+ return None
426
+
427
+ @classmethod
428
+ def get_trigger_type(cls, message: str) -> Optional[str]:
429
+ """Get the type of trigger detected in a message.
430
+
431
+ Args:
432
+ message: User message to analyze.
433
+
434
+ Returns:
435
+ "retrieval", "patterns", "outcome", or None.
436
+ """
437
+ if cls.should_retrieve(message):
438
+ return "retrieval"
439
+ if cls.should_surface_patterns(message):
440
+ return "patterns"
441
+ if cls.detect_outcome_signal(message):
442
+ return "outcome"
443
+ return None
444
+
445
+ @classmethod
446
+ def extract_query_keywords(cls, message: str) -> list[str]:
447
+ """Extract relevant keywords from a message for memory search.
448
+
449
+ Args:
450
+ message: User message to extract keywords from.
451
+
452
+ Returns:
453
+ List of keywords for search query.
454
+ """
455
+ import re
456
+
457
+ # Remove common words and trigger phrases
458
+ stop_words = {
459
+ "the", "a", "an", "is", "are", "was", "were", "be", "been",
460
+ "being", "have", "has", "had", "do", "does", "did", "will",
461
+ "would", "could", "should", "may", "might", "must", "shall",
462
+ "can", "to", "of", "in", "for", "on", "with", "at", "by",
463
+ "from", "as", "into", "through", "during", "before", "after",
464
+ "above", "below", "between", "under", "again", "further",
465
+ "then", "once", "here", "there", "when", "where", "why",
466
+ "how", "all", "each", "few", "more", "most", "other", "some",
467
+ "such", "no", "nor", "not", "only", "own", "same", "so",
468
+ "than", "too", "very", "just", "i", "me", "my", "we", "our",
469
+ "you", "your", "it", "its", "this", "that", "these", "those",
470
+ "what", "which", "who", "whom",
471
+ }
472
+
473
+ # Also remove trigger phrases
474
+ message_lower = message.lower()
475
+ for trigger in cls.RETRIEVAL_TRIGGERS + cls.PATTERN_TRIGGERS:
476
+ message_lower = message_lower.replace(trigger, " ")
477
+
478
+ # Extract words
479
+ words = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", message_lower)
480
+
481
+ # Filter stop words and short words
482
+ keywords = [
483
+ w for w in words
484
+ if w not in stop_words and len(w) > 2
485
+ ]
486
+
487
+ # Deduplicate while preserving order
488
+ seen = set()
489
+ unique_keywords = []
490
+ for kw in keywords:
491
+ if kw not in seen:
492
+ seen.add(kw)
493
+ unique_keywords.append(kw)
494
+
495
+ return unique_keywords[:10] # Limit to 10 keywords
496
+
497
+
498
+ class SessionManager:
499
+ """Manage memory sessions with Claude Code session IDs.
500
+
501
+ Integrates with Claude Code's native session management:
502
+ - Uses $CLAUDE_SESSION_ID environment variable
503
+ - Links memory sessions to Claude Code sessions
504
+ - Supports session resumption with /resume
505
+ """
506
+
507
+ def __init__(self, engine: Any = None):
508
+ """Initialize the session manager.
509
+
510
+ Args:
511
+ engine: Optional MemoryEngine instance for session operations.
512
+ """
513
+ self.engine = engine
514
+ self._current_session: Optional[str] = None
515
+ self._session_memories: list[str] = [] # Memory IDs used in session
516
+ self._session_start: Optional[datetime] = None
517
+
518
+ @property
519
+ def current_session_id(self) -> Optional[str]:
520
+ """Get the current session ID."""
521
+ return self._current_session or os.environ.get("CLAUDE_SESSION_ID")
522
+
523
+ @property
524
+ def is_active(self) -> bool:
525
+ """Check if a session is currently active."""
526
+ return self._current_session is not None
527
+
528
+ def start_session(self, claude_session_id: Optional[str] = None) -> str:
529
+ """Start or resume a memory session.
530
+
531
+ If claude_session_id is provided, links to that session.
532
+ Otherwise uses $CLAUDE_SESSION_ID from environment.
533
+
534
+ Args:
535
+ claude_session_id: Optional Claude Code session ID.
536
+
537
+ Returns:
538
+ The session ID being used.
539
+ """
540
+ session_id = claude_session_id or os.environ.get("CLAUDE_SESSION_ID")
541
+
542
+ if session_id:
543
+ self._current_session = session_id
544
+ self._session_start = datetime.now(UTC)
545
+ self._session_memories = []
546
+
547
+ return self._current_session or "default"
548
+
549
+ def end_session(self, summarize: bool = False) -> Optional[dict[str, Any]]:
550
+ """End the current session.
551
+
552
+ Args:
553
+ summarize: Whether to generate a session summary.
554
+
555
+ Returns:
556
+ Session summary dict if summarize=True, else None.
557
+ """
558
+ if not self._current_session:
559
+ return None
560
+
561
+ summary = None
562
+ if summarize:
563
+ summary = {
564
+ "session_id": self._current_session,
565
+ "start_time": self._session_start.isoformat() if self._session_start else None,
566
+ "end_time": datetime.now(UTC).isoformat(),
567
+ "memories_used": len(self._session_memories),
568
+ "memory_ids": self._session_memories[:20], # First 20
569
+ }
570
+
571
+ self._current_session = None
572
+ self._session_start = None
573
+ self._session_memories = []
574
+
575
+ return summary
576
+
577
+ def track_memory_use(self, memory_id: str) -> None:
578
+ """Track that a memory was used in this session.
579
+
580
+ Args:
581
+ memory_id: The ID of the memory that was used.
582
+ """
583
+ if memory_id not in self._session_memories:
584
+ self._session_memories.append(memory_id)
585
+
586
+ def get_session_stats(self) -> dict[str, Any]:
587
+ """Get statistics for the current session.
588
+
589
+ Returns:
590
+ Dictionary with session statistics.
591
+ """
592
+ return {
593
+ "session_id": self._current_session,
594
+ "is_active": self.is_active,
595
+ "start_time": self._session_start.isoformat() if self._session_start else None,
596
+ "memories_used": len(self._session_memories),
597
+ "duration_seconds": (
598
+ (datetime.now(UTC) - self._session_start).total_seconds()
599
+ if self._session_start else 0
600
+ ),
601
+ }
602
+
603
+
604
+ def get_plugin_root() -> Path:
605
+ """Get the plugin root directory.
606
+
607
+ Checks for CLAUDE_PLUGIN_ROOT environment variable first,
608
+ then searches up from the current file for .claude-plugin directory.
609
+
610
+ Returns:
611
+ Path to the plugin root directory.
612
+ """
613
+ # Check environment variable first (set by Claude Code)
614
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
615
+ if plugin_root:
616
+ return Path(plugin_root)
617
+
618
+ # Search up for .claude-plugin directory
619
+ current = Path(__file__).parent
620
+ while current != current.parent:
621
+ if (current / ".claude-plugin").exists():
622
+ return current
623
+ current = current.parent
624
+
625
+ # Fall back to the memory-layer package root
626
+ # Go up from plugin/__init__.py -> runtime_memory -> src -> memory-layer
627
+ return Path(__file__).parent.parent.parent.parent
628
+
629
+
630
+ def get_hook_context() -> HookContext:
631
+ """Convenience function to get current hook context.
632
+
633
+ Returns:
634
+ HookContext from current environment.
635
+ """
636
+ return HookContext.from_environment()
637
+
638
+
639
+ __all__ = [
640
+ "HookContext",
641
+ "ContextFormatter",
642
+ "SkillTriggers",
643
+ "SessionManager",
644
+ "get_plugin_root",
645
+ "get_hook_context",
646
+ ]