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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- 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
|
+
]
|