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.
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/PKG-INFO +1 -1
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/__init__.py +1 -1
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/config.py +9 -4
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/retrieval.py +110 -13
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/extraction/extractor.py +16 -4
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/provider.py +83 -12
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_config.py +9 -9
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_extractor.py +1 -1
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_hermes_provider.py +167 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_retrieval.py +302 -5
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/.gitignore +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/LICENSE +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/README.md +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/pyproject.toml +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/commands.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/daemon.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/claude_code/hooks.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/cli/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/cli/main.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/embeddings.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/engine.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/exceptions.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/legacy_env.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/logging.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/models.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/observability.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/paths.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/resilience.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/core/storage.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/extraction/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/_base.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/bridge.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/plugin.yaml +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/tools.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/hermes/trace.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/plugin/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/sdk/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/sdk/client.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/api.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/mcp.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/css/styles.css +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/index.html +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/js/api.js +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/server/static/js/app.js +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/adapter.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/claude_code_adapter.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/claude_code_parser.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/cli_bridge.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/linking.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/models.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/outcomes.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/parser.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/src/runtime_memory/tasks/unified_adapter.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/conftest.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/functional/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/test_engine_integration.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/integration/test_extractor_integration.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/performance/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/security/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/uat/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/uat/test_uat.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/__init__.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_api.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_beads.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_claude_code_tasks.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_cli.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_commands.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_config_validation.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_daemon.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_embeddings.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_engine.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_exceptions.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_hooks.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_legacy_compat.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_mcp.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_models.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_observability.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_plugin.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_resilience.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_sdk.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_setup.py +0 -0
- {runtime_memory-3.0.0 → runtime_memory-3.1.0}/tests/unit/test_storage.py +0 -0
- {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.
|
|
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
|
|
@@ -92,8 +92,13 @@ class EmbeddingConfig(BaseModel):
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
class
|
|
96
|
-
"""Retrieval
|
|
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-
|
|
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:
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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.
|
|
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-
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
106
|
-
"""Tests for
|
|
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 =
|
|
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
|
-
|
|
123
|
+
RetrievalSettings(semantic_weight=-0.1)
|
|
124
124
|
with pytest.raises(ValueError):
|
|
125
|
-
|
|
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
|
-
|
|
130
|
+
RetrievalSettings(recency_half_life_days=0)
|
|
131
131
|
with pytest.raises(ValueError):
|
|
132
|
-
|
|
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,
|
|
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-
|
|
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
|