runtime-memory 3.0.0__tar.gz → 3.1.0__tar.gz

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 (90) hide show
  1. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/PKG-INFO +1 -1
  2. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/__init__.py +1 -1
  3. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/config.py +9 -4
  4. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/retrieval.py +110 -13
  5. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/extraction/extractor.py +16 -4
  6. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/provider.py +83 -12
  7. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_config.py +9 -9
  8. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_extractor.py +1 -1
  9. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_hermes_provider.py +167 -0
  10. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_retrieval.py +302 -5
  11. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/.gitignore +0 -0
  12. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/LICENSE +0 -0
  13. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/README.md +0 -0
  14. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/pyproject.toml +0 -0
  15. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/__init__.py +0 -0
  16. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/commands.py +0 -0
  17. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/daemon.py +0 -0
  18. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/hooks.py +0 -0
  19. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/cli/__init__.py +0 -0
  20. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/cli/main.py +0 -0
  21. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/__init__.py +0 -0
  22. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/embeddings.py +0 -0
  23. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/engine.py +0 -0
  24. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/exceptions.py +0 -0
  25. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/legacy_env.py +0 -0
  26. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/logging.py +0 -0
  27. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/models.py +0 -0
  28. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/observability.py +0 -0
  29. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/paths.py +0 -0
  30. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/resilience.py +0 -0
  31. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/storage.py +0 -0
  32. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/extraction/__init__.py +0 -0
  33. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/__init__.py +0 -0
  34. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/_base.py +0 -0
  35. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/bridge.py +0 -0
  36. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/plugin.yaml +0 -0
  37. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/tools.py +0 -0
  38. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/trace.py +0 -0
  39. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/plugin/__init__.py +0 -0
  40. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/sdk/__init__.py +0 -0
  41. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/sdk/client.py +0 -0
  42. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/__init__.py +0 -0
  43. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/api.py +0 -0
  44. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/mcp.py +0 -0
  45. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/css/styles.css +0 -0
  46. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/index.html +0 -0
  47. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/js/api.js +0 -0
  48. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/js/app.js +0 -0
  49. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/__init__.py +0 -0
  50. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/adapter.py +0 -0
  51. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/claude_code_adapter.py +0 -0
  52. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/claude_code_parser.py +0 -0
  53. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/cli_bridge.py +0 -0
  54. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/linking.py +0 -0
  55. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/models.py +0 -0
  56. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/outcomes.py +0 -0
  57. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/parser.py +0 -0
  58. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/unified_adapter.py +0 -0
  59. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/__init__.py +0 -0
  60. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/conftest.py +0 -0
  61. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/functional/__init__.py +0 -0
  62. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/__init__.py +0 -0
  63. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/test_engine_integration.py +0 -0
  64. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/test_extractor_integration.py +0 -0
  65. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/performance/__init__.py +0 -0
  66. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/security/__init__.py +0 -0
  67. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/uat/__init__.py +0 -0
  68. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/uat/test_uat.py +0 -0
  69. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/__init__.py +0 -0
  70. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_api.py +0 -0
  71. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_beads.py +0 -0
  72. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_claude_code_tasks.py +0 -0
  73. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_cli.py +0 -0
  74. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_commands.py +0 -0
  75. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_config_validation.py +0 -0
  76. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_daemon.py +0 -0
  77. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_embeddings.py +0 -0
  78. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_engine.py +0 -0
  79. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_exceptions.py +0 -0
  80. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_hooks.py +0 -0
  81. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_legacy_compat.py +0 -0
  82. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_mcp.py +0 -0
  83. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_models.py +0 -0
  84. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_observability.py +0 -0
  85. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_plugin.py +0 -0
  86. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_resilience.py +0 -0
  87. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_sdk.py +0 -0
  88. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_setup.py +0 -0
  89. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_storage.py +0 -0
  90. {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_v2_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runtime-memory
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Persistent memory for AI coding agents with outcome-based learning
5
5
  Project-URL: Homepage, https://github.com/runtimenoteslabs/memory-layer
6
6
  Project-URL: Documentation, https://github.com/runtimenoteslabs/memory-layer#readme
@@ -10,7 +10,7 @@ This package provides:
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- __version__ = "3.0.0"
13
+ __version__ = "3.1.0"
14
14
  __author__ = "exitcode42"
15
15
 
16
16
  from runtime_memory.core.legacy_env import apply_legacy_env
@@ -92,8 +92,13 @@ class EmbeddingConfig(BaseModel):
92
92
  )
93
93
 
94
94
 
95
- class RetrievalConfig(BaseModel):
96
- """Retrieval system configuration."""
95
+ class RetrievalSettings(BaseModel):
96
+ """Retrieval weights as settings.
97
+
98
+ Named apart from ``RetrievalConfig`` in core/retrieval.py, which is what
99
+ actually scores a search. Two classes sharing one name is how the shipped
100
+ weights drifted from the documented ones without anyone noticing.
101
+ """
97
102
 
98
103
  semantic_weight: float = Field(
99
104
  default=0.35,
@@ -174,7 +179,7 @@ class ExtractionConfig(BaseModel):
174
179
  """LLM extraction configuration."""
175
180
 
176
181
  model: str = Field(
177
- default="claude-3-haiku-20240307",
182
+ default="claude-haiku-4-5",
178
183
  description="LLM model for extraction",
179
184
  )
180
185
  max_tokens: int = Field(
@@ -280,7 +285,7 @@ class Settings(BaseSettings):
280
285
  # Component configs
281
286
  database: DatabaseConfig = Field(default_factory=DatabaseConfig)
282
287
  embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
283
- retrieval: RetrievalConfig = Field(default_factory=RetrievalConfig)
288
+ retrieval: RetrievalSettings = Field(default_factory=RetrievalSettings)
284
289
  server: ServerConfig = Field(default_factory=ServerConfig)
285
290
  extraction: ExtractionConfig = Field(default_factory=ExtractionConfig)
286
291
  logging: LoggingConfig = Field(default_factory=LoggingConfig)
@@ -12,11 +12,12 @@ Provides intelligent memory retrieval with:
12
12
  from __future__ import annotations
13
13
 
14
14
  import math
15
+ import os
15
16
  import re
16
17
  from collections import Counter
17
18
  from dataclasses import dataclass, field
18
19
  from datetime import UTC, datetime
19
- from typing import ClassVar
20
+ from typing import Any, ClassVar
20
21
 
21
22
  from runtime_memory.core.embeddings import EmbeddingProvider # noqa: TC001
22
23
  from runtime_memory.core.logging import get_logger
@@ -32,11 +33,15 @@ EmbeddingVector = list[float]
32
33
  class RetrievalConfig:
33
34
  """Configuration for the retrieval system."""
34
35
 
35
- # Scoring weights (should sum to 1.0 for normalized scoring)
36
- semantic_weight: float = 0.5
37
- recency_weight: float = 0.25
36
+ # Scoring weights. These are the five signals the README and the design
37
+ # notes document, and they sum to 1.0. RetrievalSettings in core/config.py
38
+ # carries the same numbers for settings-file configuration; a test asserts
39
+ # the two agree, because they silently disagreed once.
40
+ semantic_weight: float = 0.35
41
+ outcome_weight: float = 0.25
42
+ recency_weight: float = 0.15
38
43
  frequency_weight: float = 0.15
39
- outcome_weight: float = 0.1
44
+ confidence_weight: float = 0.10
40
45
 
41
46
  # BM25 parameters
42
47
  bm25_k1: float = 1.5 # Term frequency saturation
@@ -53,6 +58,28 @@ class RetrievalConfig:
53
58
  frequency_log_base: float = 2.0 # Log base for frequency scaling
54
59
  max_frequency_boost: float = 2.0 # Maximum frequency boost
55
60
 
61
+ # Whether a memory's outcome record limits its frequency boost. Off by
62
+ # default, which scores exactly as before. Retrieval counts as use, so a
63
+ # memory that keeps being retrieved keeps gaining frequency even while every
64
+ # outcome recorded against it is a failure, and once its outcome score is at
65
+ # the floor of -1.0 further failures cost it nothing. With this on, the
66
+ # frequency score is scaled by 1 + outcome_score, clamped to [0, 1]: a memory
67
+ # at or above zero is untouched and a memory at the floor gets no boost.
68
+ outcome_gates_frequency: bool = False
69
+
70
+ # Two-stage retrieval. None, the default, scores exactly as before: every
71
+ # candidate competes on the full score, of which only the semantic signal
72
+ # depends on the query, so category boost, outcome and frequency can seat a
73
+ # memory that barely matches the query above one that matches it well. The
74
+ # Tier 2 evaluation found a gotcha ranked 25th of 38 on relevance injected
75
+ # first. Set to a factor of at least 1.0, a search first keeps the
76
+ # ceil(limit x factor) candidates with the highest semantic score, dropping
77
+ # any with none at all, and only that pool competes on the full score. The
78
+ # other signals then choose among relevant memories rather than decide
79
+ # whether an unrelated one gets in. At 1.0 they can only reorder; above it
80
+ # they can replace a relevant memory that keeps failing with the next one.
81
+ relevance_pool_factor: float | None = None
82
+
56
83
  # Category boosting
57
84
  category_boosts: dict[MemoryCategory, float] = field(default_factory=dict)
58
85
 
@@ -62,7 +89,16 @@ class RetrievalConfig:
62
89
  dedup_threshold: float = 0.95 # Similarity threshold for deduplication
63
90
 
64
91
  def __post_init__(self) -> None:
65
- """Set default category boosts if not provided."""
92
+ """Set default category boosts if not provided, and check the pool factor.
93
+
94
+ Raises:
95
+ ValueError: If ``relevance_pool_factor`` is below 1.0, which would make
96
+ the pool smaller than the limit it is meant to fill.
97
+ """
98
+ if self.relevance_pool_factor is not None and self.relevance_pool_factor < 1.0:
99
+ raise ValueError(
100
+ f"relevance_pool_factor must be at least 1.0, got {self.relevance_pool_factor}"
101
+ )
66
102
  if not self.category_boosts:
67
103
  self.category_boosts = {
68
104
  MemoryCategory.GOTCHA: 1.3, # Boost gotchas (important warnings)
@@ -76,6 +112,34 @@ class RetrievalConfig:
76
112
  MemoryCategory.COMMAND: 0.9,
77
113
  }
78
114
 
115
+ @classmethod
116
+ def from_env(cls, **overrides: Any) -> RetrievalConfig:
117
+ """Build a config, letting the environment override any signal weight.
118
+
119
+ Reads ``RUNTIME_MEMORY_SEMANTIC_WEIGHT`` and the matching names for
120
+ outcome, recency, frequency and confidence. Weights are applied as
121
+ given, never renormalised: an ablation that zeroes one signal should
122
+ leave the others exactly where they were, not silently reweight them.
123
+ ``RUNTIME_MEMORY_RELEVANCE_POOL_FACTOR`` turns on two-stage retrieval;
124
+ unset or empty leaves it off.
125
+
126
+ Args:
127
+ **overrides: Field values that win over both defaults and env.
128
+
129
+ Returns:
130
+ A config with any environment overrides applied.
131
+ """
132
+ values: dict[str, Any] = {}
133
+ for signal in ("semantic", "outcome", "recency", "frequency", "confidence"):
134
+ raw = os.environ.get(f"RUNTIME_MEMORY_{signal.upper()}_WEIGHT")
135
+ if raw is not None:
136
+ values[f"{signal}_weight"] = float(raw)
137
+ pool = os.environ.get("RUNTIME_MEMORY_RELEVANCE_POOL_FACTOR", "").strip()
138
+ if pool:
139
+ values["relevance_pool_factor"] = float(pool)
140
+ values.update(overrides)
141
+ return cls(**values)
142
+
79
143
 
80
144
  class BM25Index:
81
145
  """BM25 index for text search.
@@ -268,9 +332,10 @@ class HybridRetriever:
268
332
 
269
333
  Implements the scoring formula:
270
334
  final_score = (semantic_weight * semantic_score +
335
+ outcome_weight * outcome_score +
271
336
  recency_weight * recency_score +
272
337
  frequency_weight * frequency_score +
273
- outcome_weight * outcome_score) * category_boost
338
+ confidence_weight * confidence) * category_boost
274
339
  """
275
340
 
276
341
  def __init__(
@@ -396,11 +461,17 @@ class HybridRetriever:
396
461
  return []
397
462
 
398
463
  # Score all candidates
399
- results: list[SearchResult] = []
400
- for memory in candidates:
401
- result = self._score_memory(memory, query, query_embedding)
402
- if result.score >= min_score:
403
- results.append(result)
464
+ results = [
465
+ self._score_memory(memory, query, query_embedding) for memory in candidates
466
+ ]
467
+
468
+ # See RetrievalConfig.relevance_pool_factor. Relevance is decided before
469
+ # min_score, so a relevant memory with a poor record is not replaced in
470
+ # the pool by a less relevant one that happens to clear the threshold.
471
+ if self.config.relevance_pool_factor is not None:
472
+ results = self._relevance_pool(results, limit)
473
+
474
+ results = [result for result in results if result.score >= min_score]
404
475
 
405
476
  # Sort by score descending
406
477
  results.sort(key=lambda r: r.score, reverse=True)
@@ -410,6 +481,23 @@ class HybridRetriever:
410
481
 
411
482
  return results[:limit]
412
483
 
484
+ def _relevance_pool(self, results: list[SearchResult], limit: int) -> list[SearchResult]:
485
+ """Keep the candidates most relevant to the query, the first retrieval stage.
486
+
487
+ Args:
488
+ results: Scored candidates.
489
+ limit: The number of results the search will return.
490
+
491
+ Returns:
492
+ Up to ``ceil(limit x relevance_pool_factor)`` results with a semantic
493
+ score above zero, most relevant first.
494
+ """
495
+ factor = self.config.relevance_pool_factor or 1.0
496
+ size = math.ceil(limit * factor)
497
+ relevant = [result for result in results if result.semantic_score > 0]
498
+ relevant.sort(key=lambda r: r.semantic_score, reverse=True)
499
+ return relevant[:size]
500
+
413
501
  def _get_candidates(
414
502
  self,
415
503
  category: MemoryCategory | None = None,
@@ -468,15 +556,19 @@ class HybridRetriever:
468
556
  # Get outcome score (already in -1 to 1 range, normalize to 0-1)
469
557
  outcome_score = (memory.outcome_score + 1.0) / 2.0
470
558
 
559
+ # Extraction confidence, already 0-1
560
+ confidence_score = memory.confidence
561
+
471
562
  # Get category boost
472
563
  category_boost = self.config.category_boosts.get(memory.category, 1.0)
473
564
 
474
565
  # Calculate final weighted score
475
566
  final_score = (
476
567
  self.config.semantic_weight * semantic_score
568
+ + self.config.outcome_weight * outcome_score
477
569
  + self.config.recency_weight * recency_score
478
570
  + self.config.frequency_weight * frequency_score
479
- + self.config.outcome_weight * outcome_score
571
+ + self.config.confidence_weight * confidence_score
480
572
  ) * category_boost
481
573
 
482
574
  return SearchResult(
@@ -577,6 +669,11 @@ class HybridRetriever:
577
669
  max_expected = math.log(1 + 100, log_base)
578
670
  normalized = min(score / max_expected, 1.0)
579
671
 
672
+ # See RetrievalConfig.outcome_gates_frequency. Without the gate, being
673
+ # retrieved pays the same whether the memory helped or kept failing.
674
+ if self.config.outcome_gates_frequency:
675
+ normalized *= min(1.0, max(0.0, 1.0 + memory.outcome_score))
676
+
580
677
  return normalized
581
678
 
582
679
  def _deduplicate(self, results: list[SearchResult]) -> list[SearchResult]:
@@ -317,14 +317,25 @@ class ExtractionConfig:
317
317
  """Configuration for the extraction pipeline."""
318
318
 
319
319
  # LLM settings
320
- model: str = "claude-sonnet-4-20250514"
321
- """Model to use for extraction."""
320
+ model: str = "claude-sonnet-5"
321
+ """Model to use for extraction.
322
+
323
+ Extraction is a bulk structured-output task, so it runs on a cheaper model
324
+ than a reasoning workload would. Claude 4.x ids still resolve today but are
325
+ a generation behind and will eventually retire.
326
+ """
322
327
 
323
328
  max_tokens: int = 4096
324
329
  """Maximum tokens in response."""
325
330
 
326
331
  temperature: float = 0.1
327
- """Temperature for generation (low for consistency)."""
332
+ """Ignored since the move to Claude 5.
333
+
334
+ ``temperature`` was removed on Claude 4.6 and later and the API rejects it
335
+ with a 400. The field stays so existing configuration keeps loading, and it
336
+ records the original intent of deterministic extraction, but it is no longer
337
+ sent. Determinism now comes from the prompt and the schema.
338
+ """
328
339
 
329
340
  # Rate limiting
330
341
  rate_limit_rpm: int = 50
@@ -701,10 +712,11 @@ class MemoryExtractor:
701
712
  await self._rate_limiter.acquire(estimated_tokens)
702
713
 
703
714
  client = self._get_client()
715
+ # No temperature: the parameter was removed on Claude 4.6 and later and
716
+ # sending it returns a 400.
704
717
  response = await client.messages.create(
705
718
  model=self.config.model,
706
719
  max_tokens=self.config.max_tokens,
707
- temperature=self.config.temperature,
708
720
  system=system_prompt,
709
721
  messages=[{"role": "user", "content": user_prompt}],
710
722
  )
@@ -1,4 +1,4 @@
1
- """memory-layer as a Hermes Agent memory provider.
1
+ """runtime-memory as a Hermes Agent memory provider.
2
2
 
3
3
  Hermes discovers this through the ``hermes_agent.memory_providers`` entry point
4
4
  and activates it with ``memory.provider: runtimememory``. Once active it replaces
@@ -37,6 +37,8 @@ from runtime_memory.core.models import (
37
37
  MemorySource,
38
38
  Outcome,
39
39
  )
40
+ from runtime_memory.core.paths import default_db_path
41
+ from runtime_memory.core.retrieval import RetrievalConfig
40
42
  from runtime_memory.hermes._base import (
41
43
  INDICATOR_GLYPH,
42
44
  MemoryProvider,
@@ -55,9 +57,10 @@ logger = get_logger(__name__)
55
57
  PROVIDER_NAME = "runtimememory"
56
58
  PROVIDER_LABEL = "Runtime Memory"
57
59
 
58
- DEFAULT_DB_PATH = "~/.memory-layer/memories.db"
59
60
  DEFAULT_RECALL_LIMIT = 8
60
61
  DEFAULT_MIN_SCORE = 0.0
62
+ EXTRACTION_TIMEOUT = 180.0
63
+ """Seconds to wait for session-end extraction. It is one LLM round trip."""
61
64
 
62
65
  _WRITE_CONTEXTS = frozenset({"primary", ""})
63
66
  """Agent contexts allowed to write. Subagents, cron and flush runs read only."""
@@ -66,7 +69,7 @@ _MIRROR_CATEGORY = {
66
69
  "memory": MemoryCategory.CONTEXT,
67
70
  "user": MemoryCategory.PREFERENCE,
68
71
  }
69
- """Hermes built-in write targets mapped onto memory-layer categories."""
72
+ """Hermes built-in write targets mapped onto Runtime Memory categories."""
70
73
 
71
74
 
72
75
  def _env_flag(name: str, default: bool = False) -> bool:
@@ -77,6 +80,48 @@ def _env_flag(name: str, default: bool = False) -> bool:
77
80
  return raw.strip().lower() in {"1", "true", "yes", "on"}
78
81
 
79
82
 
83
+ def _extraction_unavailable_reason() -> str | None:
84
+ """Why session-end extraction cannot run, or None when it can.
85
+
86
+ Extraction needs a package and a credential, and the failure without either
87
+ is invisible: it happens on a background task at session end, where the
88
+ exception is swallowed so a lost extraction never takes a session with it.
89
+ A provider that reports extraction as on while nothing is ever extracted is
90
+ worse than one that refuses, so the check happens once at startup where it
91
+ can still be said out loud.
92
+
93
+ An unset ``ANTHROPIC_API_KEY`` does not mean there is no credential. The SDK
94
+ also accepts an auth token, a signed-in profile on disk, and workload
95
+ identity federation, so each documented source is checked before refusing.
96
+
97
+ Returns:
98
+ A reason to show the operator, or None when extraction is usable.
99
+ """
100
+ try:
101
+ import anthropic # noqa: PLC0415
102
+ except ImportError:
103
+ return "the anthropic package is not installed (pip install 'runtime-memory[extraction]')"
104
+
105
+ # Let the SDK apply its own precedence to the environment rather than
106
+ # reimplementing it. Construction does not raise when nothing resolves.
107
+ try:
108
+ client = anthropic.AsyncAnthropic()
109
+ if getattr(client, "api_key", None) or getattr(client, "auth_token", None):
110
+ return None
111
+ except Exception: # noqa: BLE001 - an unconstructable client is also a refusal
112
+ pass
113
+
114
+ if os.environ.get("ANTHROPIC_FEDERATION_RULE_ID"):
115
+ return None
116
+ if (Path.home() / ".config" / "anthropic").exists():
117
+ return None
118
+
119
+ return (
120
+ "no Anthropic credentials are configured (set ANTHROPIC_API_KEY, or sign "
121
+ "in with `ant auth login`)"
122
+ )
123
+
124
+
80
125
  def _embedding_provider_name() -> str:
81
126
  """Name the embedding backend for the engine to build.
82
127
 
@@ -90,7 +135,7 @@ def _embedding_provider_name() -> str:
90
135
 
91
136
 
92
137
  class RuntimeMemoryProvider(MemoryProvider):
93
- """Hermes memory provider backed by a local memory-layer engine."""
138
+ """Hermes memory provider backed by a local Runtime Memory engine."""
94
139
 
95
140
  pre_compress_checkpoint_api_version = 1
96
141
 
@@ -101,7 +146,9 @@ class RuntimeMemoryProvider(MemoryProvider):
101
146
  self._project: str | None = None
102
147
  self._writes_allowed: bool = True
103
148
 
104
- self._db_path: Path = Path(os.environ.get("RUNTIME_MEMORY_DB", DEFAULT_DB_PATH)).expanduser()
149
+ self._db_path: Path = Path(
150
+ os.environ.get("RUNTIME_MEMORY_DB") or default_db_path()
151
+ ).expanduser()
105
152
  self._recall_limit: int = int(
106
153
  os.environ.get("RUNTIME_MEMORY_RECALL_LIMIT", DEFAULT_RECALL_LIMIT)
107
154
  )
@@ -132,7 +179,7 @@ class RuntimeMemoryProvider(MemoryProvider):
132
179
  self._db_path.parent.mkdir(parents=True, exist_ok=True)
133
180
  return os.access(self._db_path.parent, os.W_OK)
134
181
  except OSError as exc:
135
- logger.debug(f"memory-layer unavailable: {exc}")
182
+ logger.debug(f"Runtime Memory unavailable: {exc}")
136
183
  return False
137
184
 
138
185
  def unavailable_reason(self) -> str:
@@ -158,6 +205,14 @@ class RuntimeMemoryProvider(MemoryProvider):
158
205
  if not self._writes_allowed:
159
206
  logger.info(f"Read-only in '{agent_context}' context")
160
207
 
208
+ if self._extract_on_end:
209
+ reason = _extraction_unavailable_reason()
210
+ if reason:
211
+ logger.warning(
212
+ f"Extraction at session end was requested but is off: {reason}"
213
+ )
214
+ self._extract_on_end = False
215
+
161
216
  workspace = kwargs.get("agent_workspace")
162
217
  self._project = os.environ.get("RUNTIME_MEMORY_PROJECT") or (
163
218
  Path(workspace).name if workspace else None
@@ -168,6 +223,10 @@ class RuntimeMemoryProvider(MemoryProvider):
168
223
  db_path=str(self._db_path),
169
224
  embedding_provider=_embedding_provider_name(),
170
225
  track_last_search=True,
226
+ # Signal weights come from the environment so an evaluation arm can
227
+ # ablate one, such as running with outcome weight 0 to separate a
228
+ # shared store from the learning on top of it.
229
+ retrieval_config=RetrievalConfig.from_env(),
171
230
  )
172
231
 
173
232
  engine = MemoryEngine(config=config)
@@ -180,7 +239,7 @@ class RuntimeMemoryProvider(MemoryProvider):
180
239
  spawn(self._warm(), label="warmup")
181
240
 
182
241
  logger.info(
183
- f"memory-layer ready (db={self._db_path}, project={self._project}, "
242
+ f"Runtime Memory ready (db={self._db_path}, project={self._project}, "
184
243
  f"writes={'on' if self._writes_allowed else 'off'})"
185
244
  )
186
245
 
@@ -257,12 +316,12 @@ class RuntimeMemoryProvider(MemoryProvider):
257
316
  logger.warning(f"Recall failed: {exc}")
258
317
  return ""
259
318
 
260
- if not results:
261
- return ""
262
-
263
319
  self._last_ids = [r.memory.id for r in results]
264
320
  self._last_count = len(results)
265
321
 
322
+ # Traced even when nothing came back. A recall that found nothing and a
323
+ # recall that never happened look identical in an untraced run, and the
324
+ # difference is the whole question when a store is still filling up.
266
325
  self._trace.recall(
267
326
  turn_id=self._turn_id,
268
327
  session_id=session_id or self._session_id,
@@ -271,6 +330,9 @@ class RuntimeMemoryProvider(MemoryProvider):
271
330
  project=self._project,
272
331
  latency_ms=(time.perf_counter() - started) * 1000,
273
332
  )
333
+
334
+ if not results:
335
+ return ""
274
336
  return self._format(results)
275
337
 
276
338
  def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
@@ -361,7 +423,16 @@ class RuntimeMemoryProvider(MemoryProvider):
361
423
  if self._engine is None or not messages:
362
424
  return
363
425
  logger.info(f"Session end: extraction over {len(messages)} messages")
364
- spawn(self._extract(messages), label="extraction")
426
+ # Blocking, not spawned. Hermes calls this hook and then immediately
427
+ # tears the provider down; in one-shot mode the process exits straight
428
+ # after. Fired and forgotten, the extraction never survived long enough
429
+ # to write anything, which looked exactly like extraction being off.
430
+ # Hermes documents this hook as LLM-bound and runs it on a background
431
+ # worker, so waiting here is what the contract expects.
432
+ try:
433
+ run_sync(self._extract(messages), timeout=EXTRACTION_TIMEOUT)
434
+ except Exception as exc: # a lost extraction must not take the session with it
435
+ logger.warning(f"Extraction failed: {exc}")
365
436
 
366
437
  async def _extract(self, messages: list[dict[str, Any]]) -> None:
367
438
  """Run LLM extraction over a finished session.
@@ -499,7 +570,7 @@ class RuntimeMemoryProvider(MemoryProvider):
499
570
  {
500
571
  "key": "db_path",
501
572
  "description": "SQLite store shared with Claude Code and MCP clients",
502
- "default": DEFAULT_DB_PATH,
573
+ "default": str(default_db_path()),
503
574
  "env_var": "RUNTIME_MEMORY_DB",
504
575
  "type": "text",
505
576
  },
@@ -12,7 +12,7 @@ from runtime_memory.core.config import (
12
12
  Environment,
13
13
  ExtractionConfig,
14
14
  LoggingConfig,
15
- RetrievalConfig,
15
+ RetrievalSettings,
16
16
  ServerConfig,
17
17
  Settings,
18
18
  clear_settings_cache,
@@ -102,12 +102,12 @@ class TestEmbeddingConfig:
102
102
  EmbeddingConfig(cache_size=200000)
103
103
 
104
104
 
105
- class TestRetrievalConfig:
106
- """Tests for RetrievalConfig."""
105
+ class TestRetrievalSettings:
106
+ """Tests for RetrievalSettings."""
107
107
 
108
108
  def test_default_weights(self):
109
109
  """Test default retrieval weights sum to 1."""
110
- config = RetrievalConfig()
110
+ config = RetrievalSettings()
111
111
  total = (
112
112
  config.semantic_weight
113
113
  + config.outcome_weight
@@ -120,16 +120,16 @@ class TestRetrievalConfig:
120
120
  def test_weight_validation(self):
121
121
  """Test weight validation."""
122
122
  with pytest.raises(ValueError):
123
- RetrievalConfig(semantic_weight=-0.1)
123
+ RetrievalSettings(semantic_weight=-0.1)
124
124
  with pytest.raises(ValueError):
125
- RetrievalConfig(semantic_weight=1.5)
125
+ RetrievalSettings(semantic_weight=1.5)
126
126
 
127
127
  def test_half_life_validation(self):
128
128
  """Test half life days validation."""
129
129
  with pytest.raises(ValueError):
130
- RetrievalConfig(recency_half_life_days=0)
130
+ RetrievalSettings(recency_half_life_days=0)
131
131
  with pytest.raises(ValueError):
132
- RetrievalConfig(recency_half_life_days=400)
132
+ RetrievalSettings(recency_half_life_days=400)
133
133
 
134
134
 
135
135
  class TestServerConfig:
@@ -206,7 +206,7 @@ class TestSettings:
206
206
  assert settings.env == Environment.DEVELOPMENT
207
207
  assert isinstance(settings.database, DatabaseConfig)
208
208
  assert isinstance(settings.embedding, EmbeddingConfig)
209
- assert isinstance(settings.retrieval, RetrievalConfig)
209
+ assert isinstance(settings.retrieval, RetrievalSettings)
210
210
  assert isinstance(settings.server, ServerConfig)
211
211
 
212
212
  def test_for_testing(self):
@@ -30,7 +30,7 @@ class TestExtractionConfig:
30
30
  def test_default_config(self) -> None:
31
31
  """Test default configuration values."""
32
32
  config = ExtractionConfig()
33
- assert config.model == "claude-sonnet-4-20250514"
33
+ assert config.model == "claude-sonnet-5"
34
34
  assert config.max_tokens == 4096
35
35
  assert config.temperature == 0.1
36
36
  assert config.rate_limit_rpm == 50