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,1143 @@
1
+ """Extraction pipeline for Runtime Memory.
2
+
3
+ Extracts actionable memories from conversation transcripts using LLM analysis.
4
+
5
+ Features:
6
+ - Structured extraction via LLM prompts
7
+ - Category auto-detection
8
+ - Confidence and importance scoring
9
+ - Entity detection (files, functions, errors)
10
+ - Conflict detection with existing memories
11
+ - Rate limiting for API calls
12
+ - PII detection and filtering
13
+ - Prompt injection prevention
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import re
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from datetime import UTC, datetime
24
+ from enum import Enum
25
+ from typing import TYPE_CHECKING, Any, ClassVar
26
+
27
+ from runtime_memory.core.logging import get_logger
28
+ from runtime_memory.core.models import Memory, MemoryCategory, MemorySource
29
+
30
+ if TYPE_CHECKING:
31
+ from runtime_memory.core.engine import MemoryEngine
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ # =============================================================================
37
+ # Extraction Prompts
38
+ # =============================================================================
39
+
40
+ EXTRACTION_SYSTEM_PROMPT = """You are a memory extraction assistant. Your job is to extract actionable, reusable knowledge from coding conversation transcripts.
41
+
42
+ Focus on extracting:
43
+ 1. **Decisions made and their rationale** - WHY something was chosen, not just WHAT
44
+ 2. **Patterns discovered or established** - Reusable approaches
45
+ 3. **Gotchas and pitfalls encountered** - Things that caused problems
46
+ 4. **Solutions to problems** - Error messages → fixes (troubleshooting)
47
+ 5. **User preferences expressed** - Coding style, tool preferences
48
+ 6. **Useful commands** - Shell, npm, docker commands that worked
49
+ 7. **Architecture insights** - System design decisions
50
+ 8. **Conventions** - Project-specific coding standards
51
+
52
+ Skip:
53
+ - Generic coding advice the AI would already know
54
+ - One-time specific fixes unlikely to recur
55
+ - Incomplete or abandoned approaches
56
+ - Information that's too vague to be actionable
57
+ - Personal information (names, emails, API keys, passwords)
58
+
59
+ Output JSON only. No markdown, no explanations."""
60
+
61
+ EXTRACTION_USER_PROMPT = """Extract actionable memories from this conversation transcript.
62
+
63
+ <transcript>
64
+ {transcript}
65
+ </transcript>
66
+
67
+ Return a JSON object with this exact structure:
68
+ {{
69
+ "memories": [
70
+ {{
71
+ "content": "Clear, actionable statement of what was learned",
72
+ "category": "one of: architecture, convention, decision, pattern, gotcha, workaround, troubleshooting, command, preference",
73
+ "importance": 0.0 to 1.0 (how important/reusable is this),
74
+ "confidence": 0.0 to 1.0 (how certain are we this is correct),
75
+ "entities": ["list", "of", "relevant", "entities"],
76
+ "tags": ["optional", "tags"],
77
+ "rationale": "Brief explanation of why this is worth remembering"
78
+ }}
79
+ ],
80
+ "summary": "One sentence summary of the conversation"
81
+ }}
82
+
83
+ Guidelines:
84
+ - importance: 0.9+ for critical gotchas/decisions, 0.5-0.8 for useful patterns, 0.3-0.5 for minor preferences
85
+ - confidence: 0.9+ if explicitly stated and verified, 0.6-0.8 if implied, 0.3-0.5 if inferred
86
+ - entities: Include file names, function names, package names, error types
87
+ - Each memory should be self-contained and understandable without context
88
+
89
+ Return ONLY the JSON object, no other text."""
90
+
91
+ CONFLICT_DETECTION_PROMPT = """Compare these two memories and determine their relationship.
92
+
93
+ EXISTING MEMORY:
94
+ Content: {existing_content}
95
+ Category: {existing_category}
96
+
97
+ NEW MEMORY:
98
+ Content: {new_content}
99
+ Category: {new_category}
100
+
101
+ What is the relationship between these memories?
102
+
103
+ Return a JSON object:
104
+ {{
105
+ "relationship": "one of: updates, extends, conflicts, unrelated",
106
+ "confidence": 0.0 to 1.0,
107
+ "explanation": "Brief explanation of your reasoning",
108
+ "should_supersede": true or false (should new memory replace existing?)
109
+ }}
110
+
111
+ Definitions:
112
+ - updates: New memory provides updated information on the same topic (supersedes old)
113
+ - extends: New memory adds complementary information (both should exist)
114
+ - conflicts: Memories contradict each other (needs resolution)
115
+ - unrelated: Different topics entirely
116
+
117
+ Return ONLY the JSON object."""
118
+
119
+
120
+ # =============================================================================
121
+ # Entity Detection Patterns
122
+ # =============================================================================
123
+
124
+ ENTITY_PATTERNS: dict[str, list[str]] = {
125
+ "file": [
126
+ r'\b([\w/-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|cpp|c|h|rb|php|swift|kt|scala|vue|svelte|md|json|yaml|yml|toml|sql|sh|bash|zsh))\b',
127
+ r'`([^`]+\.(?:py|js|ts|tsx|jsx|go|rs|java|cpp|c|h|rb|php))`',
128
+ ],
129
+ "module": [
130
+ r'\bfrom\s+([\w.]+)\s+import\b',
131
+ r'\bimport\s+([\w.]+)',
132
+ r'\brequire\([\'"]([^\'"]+)[\'"]\)',
133
+ ],
134
+ "error": [
135
+ r'\b([A-Z][a-zA-Z]*Error)\b',
136
+ r'\b([A-Z][a-zA-Z]*Exception)\b',
137
+ r'\b([A-Z][a-zA-Z]*Warning)\b',
138
+ ],
139
+ "function": [
140
+ r'\bdef\s+(\w+)\s*\(',
141
+ r'\bfunction\s+(\w+)\s*\(',
142
+ r'\bconst\s+(\w+)\s*=\s*(?:async\s*)?\(',
143
+ r'\b(\w+)\s*=\s*(?:async\s+)?function',
144
+ ],
145
+ "class": [
146
+ r'\bclass\s+(\w+)',
147
+ ],
148
+ "command": [
149
+ r'\$\s*([^\n]+)',
150
+ r'```(?:bash|sh|shell|zsh)\n([^`]+)```',
151
+ r'`(npm\s+\w+[^`]*)`',
152
+ r'`(pip\s+\w+[^`]*)`',
153
+ r'`(docker\s+\w+[^`]*)`',
154
+ r'`(git\s+\w+[^`]*)`',
155
+ ],
156
+ "package": [
157
+ r'\b(npm|pip|cargo|go)\s+install\s+([\w@/-]+)',
158
+ r'"([\w@/-]+)":\s*"[\d^~]',
159
+ ],
160
+ }
161
+
162
+ # PII detection patterns
163
+ PII_PATTERNS: dict[str, re.Pattern[str]] = {
164
+ "email": re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'),
165
+ "api_key": re.compile(r'\b(?:sk-|pk-|api[_-]?key[_-]?)[a-zA-Z0-9]{20,}\b', re.IGNORECASE),
166
+ "password": re.compile(r'(?:password|passwd|pwd)\s*[=:]\s*[\'"]?([^\s\'"]+)', re.IGNORECASE),
167
+ "token": re.compile(r'\b(?:token|secret)[_-]?[a-zA-Z0-9]{20,}\b', re.IGNORECASE),
168
+ "ip_address": re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'),
169
+ "phone": re.compile(r'\b(?:\+\d{1,3}[-.]?)?\(?\d{3}\)?[-.]?\d{3}[-.]?\d{4}\b'),
170
+ "ssn": re.compile(r'\b\d{3}-\d{2}-\d{4}\b'),
171
+ "credit_card": re.compile(r'\b(?:\d{4}[-\s]?){3}\d{4}\b'),
172
+ }
173
+
174
+ # Prompt injection patterns to detect and filter
175
+ INJECTION_PATTERNS: list[re.Pattern[str]] = [
176
+ re.compile(r'ignore\s+(?:all\s+)?(?:previous|above)\s+instructions?', re.IGNORECASE),
177
+ re.compile(r'disregard\s+(?:all\s+)?(?:previous|above)', re.IGNORECASE),
178
+ re.compile(r'you\s+are\s+now\s+(?:a|an|in)', re.IGNORECASE),
179
+ re.compile(r'new\s+instructions?\s*:', re.IGNORECASE),
180
+ re.compile(r'system\s*:\s*you', re.IGNORECASE),
181
+ re.compile(r'<\|(?:im_start|im_end|system|user|assistant)\|>', re.IGNORECASE),
182
+ re.compile(r'\[INST\]|\[/INST\]', re.IGNORECASE),
183
+ ]
184
+
185
+
186
+ # =============================================================================
187
+ # Data Classes
188
+ # =============================================================================
189
+
190
+ class ConflictRelationship(str, Enum):
191
+ """Relationship between conflicting memories."""
192
+
193
+ UPDATES = "updates"
194
+ """New memory updates/supersedes old memory."""
195
+
196
+ EXTENDS = "extends"
197
+ """New memory extends/complements old memory."""
198
+
199
+ CONFLICTS = "conflicts"
200
+ """Memories conflict with each other."""
201
+
202
+ UNRELATED = "unrelated"
203
+ """Memories are about different topics."""
204
+
205
+
206
+ @dataclass
207
+ class ExtractedMemory:
208
+ """A memory extracted from a transcript."""
209
+
210
+ content: str
211
+ """The extracted memory content."""
212
+
213
+ category: MemoryCategory
214
+ """Detected category."""
215
+
216
+ importance: float = 0.5
217
+ """Importance score (0.0 to 1.0)."""
218
+
219
+ confidence: float = 0.5
220
+ """Confidence score (0.0 to 1.0)."""
221
+
222
+ entities: list[str] = field(default_factory=list)
223
+ """Detected entities (files, functions, errors)."""
224
+
225
+ tags: list[str] = field(default_factory=list)
226
+ """Optional tags."""
227
+
228
+ rationale: str = ""
229
+ """Why this memory is worth remembering."""
230
+
231
+ def to_memory(self, project: str | None = None) -> Memory:
232
+ """Convert to Memory dataclass.
233
+
234
+ Args:
235
+ project: Project scope.
236
+
237
+ Returns:
238
+ Memory instance.
239
+ """
240
+ return Memory(
241
+ content=self.content,
242
+ category=self.category,
243
+ source=MemorySource.EXTRACTED,
244
+ confidence=self.confidence,
245
+ importance=self.importance,
246
+ entities=self.entities,
247
+ tags=self.tags,
248
+ project=project,
249
+ metadata={"rationale": self.rationale} if self.rationale else {},
250
+ )
251
+
252
+
253
+ @dataclass
254
+ class ConflictResult:
255
+ """Result of conflict detection between memories."""
256
+
257
+ existing_id: str
258
+ """ID of existing memory."""
259
+
260
+ relationship: ConflictRelationship
261
+ """Detected relationship."""
262
+
263
+ confidence: float
264
+ """Confidence in the relationship detection."""
265
+
266
+ explanation: str
267
+ """Explanation of the relationship."""
268
+
269
+ should_supersede: bool
270
+ """Whether new memory should supersede existing."""
271
+
272
+
273
+ @dataclass
274
+ class ExtractionResult:
275
+ """Result of extracting memories from a transcript."""
276
+
277
+ memories: list[ExtractedMemory]
278
+ """Extracted memories."""
279
+
280
+ summary: str
281
+ """Summary of the conversation."""
282
+
283
+ transcript_length: int
284
+ """Length of the original transcript."""
285
+
286
+ extraction_time_ms: float
287
+ """Time taken for extraction."""
288
+
289
+ conflicts: list[ConflictResult] = field(default_factory=list)
290
+ """Detected conflicts with existing memories."""
291
+
292
+ pii_removed: int = 0
293
+ """Number of PII instances removed."""
294
+
295
+ injection_attempts: int = 0
296
+ """Number of potential injection attempts detected."""
297
+
298
+ raw_response: str = ""
299
+ """Raw LLM response for debugging."""
300
+
301
+ error: str | None = None
302
+ """Error message if extraction failed."""
303
+
304
+ @property
305
+ def success(self) -> bool:
306
+ """Whether extraction was successful."""
307
+ return self.error is None
308
+
309
+ @property
310
+ def memory_count(self) -> int:
311
+ """Number of memories extracted."""
312
+ return len(self.memories)
313
+
314
+
315
+ @dataclass
316
+ class ExtractionConfig:
317
+ """Configuration for the extraction pipeline."""
318
+
319
+ # LLM settings
320
+ model: str = "claude-sonnet-4-20250514"
321
+ """Model to use for extraction."""
322
+
323
+ max_tokens: int = 4096
324
+ """Maximum tokens in response."""
325
+
326
+ temperature: float = 0.1
327
+ """Temperature for generation (low for consistency)."""
328
+
329
+ # Rate limiting
330
+ rate_limit_rpm: int = 50
331
+ """Rate limit: requests per minute."""
332
+
333
+ rate_limit_tpm: int = 100000
334
+ """Rate limit: tokens per minute."""
335
+
336
+ # Content limits
337
+ max_transcript_length: int = 100000
338
+ """Maximum transcript length in characters."""
339
+
340
+ min_transcript_length: int = 100
341
+ """Minimum transcript length to process."""
342
+
343
+ # Filtering
344
+ min_confidence: float = 0.3
345
+ """Minimum confidence to keep a memory."""
346
+
347
+ min_importance: float = 0.2
348
+ """Minimum importance to keep a memory."""
349
+
350
+ # Security
351
+ enable_pii_filtering: bool = True
352
+ """Whether to filter PII from transcripts."""
353
+
354
+ enable_injection_detection: bool = True
355
+ """Whether to detect prompt injection attempts."""
356
+
357
+ # Conflict detection
358
+ enable_conflict_detection: bool = True
359
+ """Whether to detect conflicts with existing memories."""
360
+
361
+ conflict_similarity_threshold: float = 0.7
362
+ """Similarity threshold for potential conflicts."""
363
+
364
+
365
+ # =============================================================================
366
+ # Rate Limiter
367
+ # =============================================================================
368
+
369
+ class RateLimiter:
370
+ """Simple rate limiter using token bucket algorithm."""
371
+
372
+ def __init__(self, requests_per_minute: int, tokens_per_minute: int) -> None:
373
+ """Initialize rate limiter.
374
+
375
+ Args:
376
+ requests_per_minute: Maximum requests per minute.
377
+ tokens_per_minute: Maximum tokens per minute.
378
+ """
379
+ self.rpm = requests_per_minute
380
+ self.tpm = tokens_per_minute
381
+ self._request_times: list[float] = []
382
+ self._token_counts: list[tuple[float, int]] = []
383
+ self._lock = asyncio.Lock()
384
+
385
+ async def acquire(self, tokens: int = 0) -> None:
386
+ """Wait until request can proceed.
387
+
388
+ Args:
389
+ tokens: Estimated tokens for this request.
390
+ """
391
+ async with self._lock:
392
+ now = time.time()
393
+ minute_ago = now - 60
394
+
395
+ # Clean old entries
396
+ self._request_times = [t for t in self._request_times if t > minute_ago]
397
+ self._token_counts = [(t, c) for t, c in self._token_counts if t > minute_ago]
398
+
399
+ # Check request limit
400
+ while len(self._request_times) >= self.rpm:
401
+ sleep_time = self._request_times[0] - minute_ago
402
+ if sleep_time > 0:
403
+ await asyncio.sleep(sleep_time)
404
+ now = time.time()
405
+ minute_ago = now - 60
406
+ self._request_times = [t for t in self._request_times if t > minute_ago]
407
+
408
+ # Check token limit
409
+ current_tokens = sum(c for _, c in self._token_counts)
410
+ while current_tokens + tokens > self.tpm:
411
+ if self._token_counts:
412
+ sleep_time = self._token_counts[0][0] - minute_ago
413
+ if sleep_time > 0:
414
+ await asyncio.sleep(sleep_time)
415
+ now = time.time()
416
+ minute_ago = now - 60
417
+ self._token_counts = [(t, c) for t, c in self._token_counts if t > minute_ago]
418
+ current_tokens = sum(c for _, c in self._token_counts)
419
+
420
+ # Record this request
421
+ self._request_times.append(now)
422
+ if tokens > 0:
423
+ self._token_counts.append((now, tokens))
424
+
425
+
426
+ # =============================================================================
427
+ # Main Extractor Class
428
+ # =============================================================================
429
+
430
+ class MemoryExtractor:
431
+ """Extracts memories from conversation transcripts using LLM analysis."""
432
+
433
+ # Category keywords for auto-detection fallback
434
+ CATEGORY_KEYWORDS: ClassVar[dict[MemoryCategory, set[str]]] = {
435
+ MemoryCategory.ARCHITECTURE: {
436
+ "architecture", "design", "microservice", "monolith", "database",
437
+ "system", "component", "layer", "api", "service",
438
+ },
439
+ MemoryCategory.CONVENTION: {
440
+ "convention", "standard", "naming", "format", "style", "lint",
441
+ "rule", "guideline", "best practice",
442
+ },
443
+ MemoryCategory.DECISION: {
444
+ "decided", "chose", "choice", "decision", "why", "because",
445
+ "rationale", "trade-off", "alternative",
446
+ },
447
+ MemoryCategory.PATTERN: {
448
+ "pattern", "approach", "technique", "method", "way to",
449
+ "how to", "idiom", "recipe",
450
+ },
451
+ MemoryCategory.GOTCHA: {
452
+ "gotcha", "watch out", "careful", "warning", "trap", "pitfall",
453
+ "don't", "avoid", "never", "beware", "caution", "caveat",
454
+ },
455
+ MemoryCategory.WORKAROUND: {
456
+ "workaround", "hack", "temporary", "quick fix", "bypass",
457
+ "until", "for now", "interim",
458
+ },
459
+ MemoryCategory.TROUBLESHOOTING: {
460
+ "error", "exception", "bug", "fix", "crash", "fail", "issue",
461
+ "traceback", "debug", "solve", "solution",
462
+ },
463
+ MemoryCategory.COMMAND: {
464
+ "command", "cli", "terminal", "shell", "npm", "pip", "docker",
465
+ "git", "run", "execute", "script",
466
+ },
467
+ MemoryCategory.PREFERENCE: {
468
+ "prefer", "like", "want", "favorite", "always", "usually",
469
+ "habit", "style",
470
+ },
471
+ }
472
+
473
+ def __init__(
474
+ self,
475
+ config: ExtractionConfig | None = None,
476
+ api_key: str | None = None,
477
+ ) -> None:
478
+ """Initialize the extractor.
479
+
480
+ Args:
481
+ config: Extraction configuration.
482
+ api_key: Anthropic API key (or use ANTHROPIC_API_KEY env var).
483
+ """
484
+ self.config = config or ExtractionConfig()
485
+ self._api_key = api_key
486
+ self._client: Any = None
487
+ self._rate_limiter = RateLimiter(
488
+ self.config.rate_limit_rpm,
489
+ self.config.rate_limit_tpm,
490
+ )
491
+
492
+ def _get_client(self) -> Any:
493
+ """Get or create the Anthropic client."""
494
+ if self._client is None:
495
+ try:
496
+ import anthropic
497
+ except ImportError as e:
498
+ raise ImportError(
499
+ "anthropic package required for extraction. "
500
+ "Install with: pip install 'runtime-memory[phase1]'"
501
+ ) from e
502
+
503
+ self._client = anthropic.AsyncAnthropic(api_key=self._api_key)
504
+
505
+ return self._client
506
+
507
+ # =========================================================================
508
+ # Security: PII and Injection Detection
509
+ # =========================================================================
510
+
511
+ def detect_pii(self, text: str) -> list[tuple[str, str, int, int]]:
512
+ """Detect PII in text.
513
+
514
+ Args:
515
+ text: Text to scan.
516
+
517
+ Returns:
518
+ List of (pii_type, matched_text, start, end) tuples.
519
+ """
520
+ findings: list[tuple[str, str, int, int]] = []
521
+ for pii_type, pattern in PII_PATTERNS.items():
522
+ for match in pattern.finditer(text):
523
+ findings.append((pii_type, match.group(), match.start(), match.end()))
524
+ return findings
525
+
526
+ def filter_pii(self, text: str) -> tuple[str, int]:
527
+ """Remove PII from text.
528
+
529
+ Args:
530
+ text: Text to filter.
531
+
532
+ Returns:
533
+ Tuple of (filtered_text, count_removed).
534
+ """
535
+ findings = self.detect_pii(text)
536
+ if not findings:
537
+ return text, 0
538
+
539
+ # Sort by position descending to replace from end
540
+ findings.sort(key=lambda x: x[2], reverse=True)
541
+
542
+ result = text
543
+ for pii_type, _, start, end in findings:
544
+ placeholder = f"[{pii_type.upper()}_REDACTED]"
545
+ result = result[:start] + placeholder + result[end:]
546
+
547
+ return result, len(findings)
548
+
549
+ def detect_injection_attempts(self, text: str) -> list[str]:
550
+ """Detect potential prompt injection attempts.
551
+
552
+ Args:
553
+ text: Text to scan.
554
+
555
+ Returns:
556
+ List of detected injection patterns.
557
+ """
558
+ attempts = []
559
+ for pattern in INJECTION_PATTERNS:
560
+ matches = pattern.findall(text)
561
+ if matches:
562
+ attempts.extend(matches if isinstance(matches[0], str) else [m[0] for m in matches])
563
+ return attempts
564
+
565
+ def sanitize_for_prompt(self, text: str) -> str:
566
+ """Sanitize text for use in prompts.
567
+
568
+ Args:
569
+ text: Text to sanitize.
570
+
571
+ Returns:
572
+ Sanitized text.
573
+ """
574
+ # Escape any prompt-like patterns
575
+ text = re.sub(r'<\|', '<|', text) # Use fullwidth vertical line
576
+ text = re.sub(r'\|>', '|>', text)
577
+ text = re.sub(r'\[INST\]', '[inst]', text, flags=re.IGNORECASE)
578
+ text = re.sub(r'\[/INST\]', '[/inst]', text, flags=re.IGNORECASE)
579
+ return text
580
+
581
+ # =========================================================================
582
+ # Entity Detection
583
+ # =========================================================================
584
+
585
+ def extract_entities(self, text: str) -> dict[str, list[str]]:
586
+ """Extract entities from text.
587
+
588
+ Args:
589
+ text: Text to analyze.
590
+
591
+ Returns:
592
+ Dictionary mapping entity type to list of entities.
593
+ """
594
+ entities: dict[str, list[str]] = {}
595
+
596
+ for entity_type, patterns in ENTITY_PATTERNS.items():
597
+ found: set[str] = set()
598
+ for pattern in patterns:
599
+ matches = re.findall(pattern, text)
600
+ for match in matches:
601
+ if isinstance(match, tuple):
602
+ # Multi-group pattern, take first non-empty
603
+ for group in match:
604
+ if group:
605
+ found.add(group.strip())
606
+ break
607
+ else:
608
+ found.add(match.strip())
609
+
610
+ if found:
611
+ entities[entity_type] = sorted(found)
612
+
613
+ return entities
614
+
615
+ def flatten_entities(self, entities: dict[str, list[str]]) -> list[str]:
616
+ """Flatten entity dictionary to list.
617
+
618
+ Args:
619
+ entities: Entity dictionary.
620
+
621
+ Returns:
622
+ Flat list of unique entities.
623
+ """
624
+ all_entities: set[str] = set()
625
+ for entity_list in entities.values():
626
+ all_entities.update(entity_list)
627
+ return sorted(all_entities)
628
+
629
+ # =========================================================================
630
+ # Category Detection
631
+ # =========================================================================
632
+
633
+ def detect_category(self, content: str) -> tuple[MemoryCategory, float]:
634
+ """Detect memory category from content.
635
+
636
+ Args:
637
+ content: Memory content.
638
+
639
+ Returns:
640
+ Tuple of (category, confidence).
641
+ """
642
+ content_lower = content.lower()
643
+ scores: dict[MemoryCategory, int] = {}
644
+
645
+ for category, keywords in self.CATEGORY_KEYWORDS.items():
646
+ score = sum(1 for keyword in keywords if keyword in content_lower)
647
+ if score > 0:
648
+ scores[category] = score
649
+
650
+ if not scores:
651
+ # Default to DECISION if no keywords match
652
+ return MemoryCategory.DECISION, 0.3
653
+
654
+ best_category = max(scores, key=lambda k: scores[k])
655
+ max_score = scores[best_category]
656
+
657
+ # Calculate confidence based on number of keyword matches
658
+ confidence = min(0.4 + (max_score * 0.15), 0.9)
659
+
660
+ return best_category, confidence
661
+
662
+ def parse_category(self, category_str: str) -> MemoryCategory:
663
+ """Parse category string to enum.
664
+
665
+ Args:
666
+ category_str: Category string from LLM.
667
+
668
+ Returns:
669
+ MemoryCategory enum.
670
+ """
671
+ category_str = category_str.lower().strip()
672
+ try:
673
+ return MemoryCategory(category_str)
674
+ except ValueError:
675
+ # Try to match partial
676
+ for category in MemoryCategory:
677
+ if category.value in category_str or category_str in category.value:
678
+ return category
679
+ return MemoryCategory.DECISION
680
+
681
+ # =========================================================================
682
+ # LLM Extraction
683
+ # =========================================================================
684
+
685
+ async def _call_llm(
686
+ self,
687
+ system_prompt: str,
688
+ user_prompt: str,
689
+ estimated_tokens: int = 1000,
690
+ ) -> str:
691
+ """Call the LLM with rate limiting.
692
+
693
+ Args:
694
+ system_prompt: System prompt.
695
+ user_prompt: User prompt.
696
+ estimated_tokens: Estimated tokens for rate limiting.
697
+
698
+ Returns:
699
+ LLM response text.
700
+ """
701
+ await self._rate_limiter.acquire(estimated_tokens)
702
+
703
+ client = self._get_client()
704
+ response = await client.messages.create(
705
+ model=self.config.model,
706
+ max_tokens=self.config.max_tokens,
707
+ temperature=self.config.temperature,
708
+ system=system_prompt,
709
+ messages=[{"role": "user", "content": user_prompt}],
710
+ )
711
+
712
+ return response.content[0].text
713
+
714
+ def _parse_extraction_response(self, response: str) -> tuple[list[ExtractedMemory], str]:
715
+ """Parse LLM extraction response.
716
+
717
+ Args:
718
+ response: Raw LLM response.
719
+
720
+ Returns:
721
+ Tuple of (memories, summary).
722
+ """
723
+ # Try to extract JSON from response
724
+ json_match = re.search(r'\{[\s\S]*\}', response)
725
+ if not json_match:
726
+ raise ValueError("No JSON found in response")
727
+
728
+ try:
729
+ data = json.loads(json_match.group())
730
+ except json.JSONDecodeError as e:
731
+ raise ValueError(f"Invalid JSON: {e}") from e
732
+
733
+ memories: list[ExtractedMemory] = []
734
+ raw_memories = data.get("memories", [])
735
+
736
+ for raw in raw_memories:
737
+ if not isinstance(raw, dict):
738
+ continue
739
+
740
+ content = raw.get("content", "").strip()
741
+ if not content:
742
+ continue
743
+
744
+ # Parse category
745
+ category_str = raw.get("category", "decision")
746
+ category = self.parse_category(category_str)
747
+
748
+ # Parse scores with validation
749
+ importance = float(raw.get("importance", 0.5))
750
+ importance = max(0.0, min(1.0, importance))
751
+
752
+ confidence = float(raw.get("confidence", 0.5))
753
+ confidence = max(0.0, min(1.0, confidence))
754
+
755
+ # Parse lists
756
+ entities = raw.get("entities", [])
757
+ if not isinstance(entities, list):
758
+ entities = []
759
+ entities = [str(e).strip() for e in entities if e]
760
+
761
+ tags = raw.get("tags", [])
762
+ if not isinstance(tags, list):
763
+ tags = []
764
+ tags = [str(t).strip().lower() for t in tags if t]
765
+
766
+ rationale = str(raw.get("rationale", "")).strip()
767
+
768
+ memories.append(ExtractedMemory(
769
+ content=content,
770
+ category=category,
771
+ importance=importance,
772
+ confidence=confidence,
773
+ entities=entities,
774
+ tags=tags,
775
+ rationale=rationale,
776
+ ))
777
+
778
+ summary = str(data.get("summary", "")).strip()
779
+ return memories, summary
780
+
781
+ async def extract_from_transcript(
782
+ self,
783
+ transcript: str,
784
+ project: str | None = None,
785
+ existing_memories: list[Memory] | None = None,
786
+ ) -> ExtractionResult:
787
+ """Extract memories from a conversation transcript.
788
+
789
+ Args:
790
+ transcript: The conversation transcript.
791
+ project: Optional project context.
792
+ existing_memories: Optional list of existing memories for conflict detection.
793
+
794
+ Returns:
795
+ ExtractionResult with extracted memories.
796
+ """
797
+ start_time = time.time()
798
+ transcript_length = len(transcript)
799
+
800
+ # Validate transcript length
801
+ if transcript_length < self.config.min_transcript_length:
802
+ return ExtractionResult(
803
+ memories=[],
804
+ summary="Transcript too short for extraction",
805
+ transcript_length=transcript_length,
806
+ extraction_time_ms=0,
807
+ error="Transcript too short",
808
+ )
809
+
810
+ if transcript_length > self.config.max_transcript_length:
811
+ # Truncate to max length
812
+ transcript = transcript[:self.config.max_transcript_length]
813
+ logger.warning(f"Transcript truncated from {transcript_length} to {self.config.max_transcript_length}")
814
+
815
+ # Security: Detect injection attempts
816
+ injection_attempts = 0
817
+ if self.config.enable_injection_detection:
818
+ attempts = self.detect_injection_attempts(transcript)
819
+ injection_attempts = len(attempts)
820
+ if injection_attempts > 0:
821
+ logger.warning(f"Detected {injection_attempts} potential injection attempts")
822
+
823
+ # Security: Filter PII
824
+ pii_removed = 0
825
+ if self.config.enable_pii_filtering:
826
+ transcript, pii_removed = self.filter_pii(transcript)
827
+ if pii_removed > 0:
828
+ logger.info(f"Removed {pii_removed} PII instances")
829
+
830
+ # Sanitize transcript for prompt
831
+ transcript = self.sanitize_for_prompt(transcript)
832
+
833
+ # Build prompt
834
+ user_prompt = EXTRACTION_USER_PROMPT.format(transcript=transcript)
835
+ estimated_tokens = len(transcript) // 4 + 1000
836
+
837
+ try:
838
+ # Call LLM
839
+ response = await self._call_llm(
840
+ EXTRACTION_SYSTEM_PROMPT,
841
+ user_prompt,
842
+ estimated_tokens,
843
+ )
844
+
845
+ # Parse response
846
+ memories, summary = self._parse_extraction_response(response)
847
+
848
+ # Filter by minimum thresholds
849
+ memories = [
850
+ m for m in memories
851
+ if m.confidence >= self.config.min_confidence
852
+ and m.importance >= self.config.min_importance
853
+ ]
854
+
855
+ # Enhance with additional entity detection
856
+ for memory in memories:
857
+ detected = self.extract_entities(memory.content)
858
+ existing_entities = set(memory.entities)
859
+ for entity_list in detected.values():
860
+ for entity in entity_list:
861
+ if entity not in existing_entities:
862
+ memory.entities.append(entity)
863
+
864
+ # Conflict detection
865
+ conflicts: list[ConflictResult] = []
866
+ if self.config.enable_conflict_detection and existing_memories:
867
+ conflicts = await self._detect_conflicts(memories, existing_memories)
868
+
869
+ extraction_time = (time.time() - start_time) * 1000
870
+
871
+ return ExtractionResult(
872
+ memories=memories,
873
+ summary=summary,
874
+ transcript_length=transcript_length,
875
+ extraction_time_ms=extraction_time,
876
+ conflicts=conflicts,
877
+ pii_removed=pii_removed,
878
+ injection_attempts=injection_attempts,
879
+ raw_response=response,
880
+ )
881
+
882
+ except Exception as e:
883
+ extraction_time = (time.time() - start_time) * 1000
884
+ logger.error(f"Extraction failed: {e}")
885
+ return ExtractionResult(
886
+ memories=[],
887
+ summary="",
888
+ transcript_length=transcript_length,
889
+ extraction_time_ms=extraction_time,
890
+ pii_removed=pii_removed,
891
+ injection_attempts=injection_attempts,
892
+ error=str(e),
893
+ )
894
+
895
+ # =========================================================================
896
+ # Conflict Detection
897
+ # =========================================================================
898
+
899
+ async def _detect_conflicts(
900
+ self,
901
+ new_memories: list[ExtractedMemory],
902
+ existing_memories: list[Memory],
903
+ ) -> list[ConflictResult]:
904
+ """Detect conflicts between new and existing memories.
905
+
906
+ Args:
907
+ new_memories: Newly extracted memories.
908
+ existing_memories: Existing memories to check against.
909
+
910
+ Returns:
911
+ List of conflict results.
912
+ """
913
+ conflicts: list[ConflictResult] = []
914
+
915
+ for new_mem in new_memories:
916
+ # Find potentially related existing memories
917
+ for existing in existing_memories:
918
+ # Quick category match check
919
+ if new_mem.category != existing.category:
920
+ continue
921
+
922
+ # Check for entity overlap
923
+ new_entities = set(new_mem.entities)
924
+ existing_entities = set(existing.entities)
925
+ if not new_entities.intersection(existing_entities):
926
+ # No entity overlap, check content similarity heuristic
927
+ if not self._content_similar(new_mem.content, existing.content):
928
+ continue
929
+
930
+ # Potential conflict found, use LLM to classify
931
+ try:
932
+ conflict = await self._classify_conflict(new_mem, existing)
933
+ if conflict.relationship != ConflictRelationship.UNRELATED:
934
+ conflicts.append(conflict)
935
+ except Exception as e:
936
+ logger.warning(f"Conflict classification failed: {e}")
937
+
938
+ return conflicts
939
+
940
+ def _content_similar(self, content1: str, content2: str) -> bool:
941
+ """Quick heuristic check for content similarity.
942
+
943
+ Args:
944
+ content1: First content.
945
+ content2: Second content.
946
+
947
+ Returns:
948
+ True if contents appear similar.
949
+ """
950
+ # Simple word overlap check
951
+ words1 = set(content1.lower().split())
952
+ words2 = set(content2.lower().split())
953
+
954
+ if not words1 or not words2:
955
+ return False
956
+
957
+ overlap = len(words1.intersection(words2))
958
+ min_len = min(len(words1), len(words2))
959
+
960
+ return overlap / min_len >= 0.3
961
+
962
+ async def _classify_conflict(
963
+ self,
964
+ new_memory: ExtractedMemory,
965
+ existing: Memory,
966
+ ) -> ConflictResult:
967
+ """Classify the relationship between new and existing memory.
968
+
969
+ Args:
970
+ new_memory: New extracted memory.
971
+ existing: Existing memory.
972
+
973
+ Returns:
974
+ ConflictResult with classification.
975
+ """
976
+ prompt = CONFLICT_DETECTION_PROMPT.format(
977
+ existing_content=existing.content,
978
+ existing_category=existing.category.value,
979
+ new_content=new_memory.content,
980
+ new_category=new_memory.category.value,
981
+ )
982
+
983
+ response = await self._call_llm(
984
+ "You are a memory conflict analyzer. Classify relationships between memories.",
985
+ prompt,
986
+ 500,
987
+ )
988
+
989
+ # Parse response
990
+ json_match = re.search(r'\{[\s\S]*\}', response)
991
+ if not json_match:
992
+ raise ValueError("No JSON in conflict response")
993
+
994
+ data = json.loads(json_match.group())
995
+
996
+ relationship_str = data.get("relationship", "unrelated").lower()
997
+ try:
998
+ relationship = ConflictRelationship(relationship_str)
999
+ except ValueError:
1000
+ relationship = ConflictRelationship.UNRELATED
1001
+
1002
+ return ConflictResult(
1003
+ existing_id=existing.id,
1004
+ relationship=relationship,
1005
+ confidence=float(data.get("confidence", 0.5)),
1006
+ explanation=str(data.get("explanation", "")),
1007
+ should_supersede=bool(data.get("should_supersede", False)),
1008
+ )
1009
+
1010
+ # =========================================================================
1011
+ # High-Level API
1012
+ # =========================================================================
1013
+
1014
+ async def extract_and_store(
1015
+ self,
1016
+ transcript: str,
1017
+ engine: MemoryEngine,
1018
+ project: str | None = None,
1019
+ ) -> ExtractionResult:
1020
+ """Extract memories and store them in the engine.
1021
+
1022
+ Args:
1023
+ transcript: Conversation transcript.
1024
+ engine: Memory engine to store in.
1025
+ project: Project scope.
1026
+
1027
+ Returns:
1028
+ ExtractionResult with stored memories.
1029
+ """
1030
+ # Get existing memories for conflict detection
1031
+ existing_memories: list[Memory] = []
1032
+ if self.config.enable_conflict_detection:
1033
+ existing_memories = await engine.list(project=project, limit=100)
1034
+
1035
+ # Extract
1036
+ result = await self.extract_from_transcript(
1037
+ transcript=transcript,
1038
+ project=project,
1039
+ existing_memories=existing_memories,
1040
+ )
1041
+
1042
+ if not result.success or not result.memories:
1043
+ return result
1044
+
1045
+ # Handle conflicts and store
1046
+ for memory in result.memories:
1047
+ # Check if this memory has conflicts that require superseding
1048
+ supersedes_id: str | None = None
1049
+ for conflict in result.conflicts:
1050
+ if conflict.should_supersede and conflict.relationship == ConflictRelationship.UPDATES:
1051
+ # Find if this conflict is for the current memory
1052
+ # (simplified: just use the first supersede candidate)
1053
+ supersedes_id = conflict.existing_id
1054
+ break
1055
+
1056
+ # Store the memory
1057
+ await engine.add(
1058
+ content=memory.content,
1059
+ category=memory.category,
1060
+ project=project,
1061
+ source=MemorySource.EXTRACTED,
1062
+ confidence=memory.confidence,
1063
+ importance=memory.importance,
1064
+ entities=memory.entities,
1065
+ tags=memory.tags,
1066
+ supersedes=supersedes_id,
1067
+ metadata={"rationale": memory.rationale} if memory.rationale else {},
1068
+ )
1069
+
1070
+ logger.info(f"Stored {len(result.memories)} memories from extraction")
1071
+ return result
1072
+
1073
+
1074
+ # =============================================================================
1075
+ # Convenience Functions
1076
+ # =============================================================================
1077
+
1078
+ async def extract_from_transcript(
1079
+ transcript: str,
1080
+ project: str | None = None,
1081
+ config: ExtractionConfig | None = None,
1082
+ api_key: str | None = None,
1083
+ ) -> ExtractionResult:
1084
+ """Extract memories from a transcript.
1085
+
1086
+ Convenience function for one-off extraction.
1087
+
1088
+ Args:
1089
+ transcript: Conversation transcript.
1090
+ project: Project scope.
1091
+ config: Extraction configuration.
1092
+ api_key: Anthropic API key.
1093
+
1094
+ Returns:
1095
+ ExtractionResult.
1096
+ """
1097
+ extractor = MemoryExtractor(config=config, api_key=api_key)
1098
+ return await extractor.extract_from_transcript(transcript, project=project)
1099
+
1100
+
1101
+ def detect_entities(text: str) -> dict[str, list[str]]:
1102
+ """Detect entities in text.
1103
+
1104
+ Convenience function for entity detection without LLM.
1105
+
1106
+ Args:
1107
+ text: Text to analyze.
1108
+
1109
+ Returns:
1110
+ Dictionary of entity types to entity lists.
1111
+ """
1112
+ extractor = MemoryExtractor()
1113
+ return extractor.extract_entities(text)
1114
+
1115
+
1116
+ def detect_pii(text: str) -> list[tuple[str, str, int, int]]:
1117
+ """Detect PII in text.
1118
+
1119
+ Convenience function for PII detection.
1120
+
1121
+ Args:
1122
+ text: Text to scan.
1123
+
1124
+ Returns:
1125
+ List of (pii_type, matched_text, start, end) tuples.
1126
+ """
1127
+ extractor = MemoryExtractor()
1128
+ return extractor.detect_pii(text)
1129
+
1130
+
1131
+ def filter_pii(text: str) -> tuple[str, int]:
1132
+ """Filter PII from text.
1133
+
1134
+ Convenience function for PII filtering.
1135
+
1136
+ Args:
1137
+ text: Text to filter.
1138
+
1139
+ Returns:
1140
+ Tuple of (filtered_text, count_removed).
1141
+ """
1142
+ extractor = MemoryExtractor()
1143
+ return extractor.filter_pii(text)