etchmem 0.1.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.
- etchmem/__init__.py +25 -0
- etchmem/config.py +109 -0
- etchmem/connector/__init__.py +0 -0
- etchmem/connector/base.py +35 -0
- etchmem/connector/remote.py +121 -0
- etchmem/consolidate/__init__.py +0 -0
- etchmem/consolidate/worker.py +559 -0
- etchmem/engine.py +191 -0
- etchmem/models.py +147 -0
- etchmem/recall/__init__.py +0 -0
- etchmem/recall/recaller.py +154 -0
- etchmem/stores/__init__.py +0 -0
- etchmem/stores/base.py +171 -0
- etchmem/stores/chroma_store.py +647 -0
- etchmem/util/__init__.py +0 -0
- etchmem/util/affectability.py +104 -0
- etchmem/util/hashing.py +49 -0
- etchmem-0.1.0.dist-info/METADATA +29 -0
- etchmem-0.1.0.dist-info/RECORD +22 -0
- etchmem-0.1.0.dist-info/WHEEL +5 -0
- etchmem-0.1.0.dist-info/licenses/LICENSE +201 -0
- etchmem-0.1.0.dist-info/top_level.txt +1 -0
etchmem/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skillmem — a reconsolidating three-tier memory engine for LLM agents.
|
|
3
|
+
|
|
4
|
+
Public surface::
|
|
5
|
+
|
|
6
|
+
from etchmem import Engine
|
|
7
|
+
|
|
8
|
+
engine = Engine()
|
|
9
|
+
engine.remember("text")
|
|
10
|
+
results = engine.recall("query")
|
|
11
|
+
summary = engine.consolidate()
|
|
12
|
+
"""
|
|
13
|
+
from etchmem.engine import Engine
|
|
14
|
+
from etchmem.config import Config
|
|
15
|
+
from etchmem.models import SearchResult, Record, InjectedArticle
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Engine",
|
|
19
|
+
"Config",
|
|
20
|
+
"SearchResult",
|
|
21
|
+
"Record",
|
|
22
|
+
"InjectedArticle",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
etchmem/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skillmem configuration.
|
|
3
|
+
|
|
4
|
+
All settings can be overridden by passing a Config instance to Engine().
|
|
5
|
+
Environment variables are read at import time as defaults.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
# ── Storage ───────────────────────────────────────────────────────────
|
|
16
|
+
data_dir: str = field(
|
|
17
|
+
default_factory=lambda: os.environ.get("SKILLMEM_DATA_DIR", ".skillmem")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ── Collections ───────────────────────────────────────────────────────
|
|
21
|
+
collection_relational: str = "relational"
|
|
22
|
+
collection_buffer: str = "buffer"
|
|
23
|
+
collection_injected: str = "injected"
|
|
24
|
+
|
|
25
|
+
# ── TTL (seconds) for relational records ─────────────────────────────
|
|
26
|
+
# Default: 7 days
|
|
27
|
+
relational_ttl_seconds: int = field(
|
|
28
|
+
default_factory=lambda: int(
|
|
29
|
+
os.environ.get("SKILLMEM_RELATIONAL_TTL", str(7 * 24 * 3600))
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# ── Recall ────────────────────────────────────────────────────────────
|
|
34
|
+
recall_top_k_injected: int = 5
|
|
35
|
+
recall_top_k_relational: int = 5
|
|
36
|
+
|
|
37
|
+
# Asymmetric scoring weights
|
|
38
|
+
recall_injected_weight: float = 1.0 # high-trust base
|
|
39
|
+
recall_relational_weight: float = 0.6 # freshness-boosted, confidence-discounted
|
|
40
|
+
|
|
41
|
+
# ── Consolidation thresholds ──────────────────────────────────────────
|
|
42
|
+
# branch_threshold: cosine distance above which a cluster splits into
|
|
43
|
+
# multiple injected articles (distinct experiences → distinct articles).
|
|
44
|
+
# Higher = harder to branch.
|
|
45
|
+
branch_threshold: float = field(
|
|
46
|
+
default_factory=lambda: float(
|
|
47
|
+
os.environ.get("SKILLMEM_BRANCH_THRESHOLD", "0.4")
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# dedup_delete_threshold: cosine distance below which two injected
|
|
52
|
+
# articles are "the same" → older is superseded.
|
|
53
|
+
# MUST be strictly tighter (lower) than branch_threshold.
|
|
54
|
+
dedup_delete_threshold: float = field(
|
|
55
|
+
default_factory=lambda: float(
|
|
56
|
+
os.environ.get("SKILLMEM_DEDUP_THRESHOLD", "0.15")
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# ── Affectability buckets ─────────────────────────────────────────────
|
|
61
|
+
affectability_drop_below: float = 0.3 # < this → drop (noise)
|
|
62
|
+
affectability_keep_below: float = 0.7 # 0.3–0.7 → keep in buffer
|
|
63
|
+
affectability_default: float = 0.5 # default when no hint given
|
|
64
|
+
|
|
65
|
+
# ── LLM connector ─────────────────────────────────────────────────────
|
|
66
|
+
llm_provider: str = field(
|
|
67
|
+
default_factory=lambda: os.environ.get("SKILLMEM_LLM_PROVIDER", "auto")
|
|
68
|
+
) # "openai" | "anthropic" | "auto"
|
|
69
|
+
|
|
70
|
+
openai_api_key: str | None = field(
|
|
71
|
+
default_factory=lambda: os.environ.get("OPENAI_API_KEY")
|
|
72
|
+
)
|
|
73
|
+
openai_model: str = field(
|
|
74
|
+
default_factory=lambda: os.environ.get("SKILLMEM_OPENAI_MODEL", "gpt-4o-mini")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
anthropic_api_key: str | None = field(
|
|
78
|
+
default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY")
|
|
79
|
+
)
|
|
80
|
+
anthropic_model: str = field(
|
|
81
|
+
default_factory=lambda: os.environ.get(
|
|
82
|
+
"SKILLMEM_ANTHROPIC_MODEL", "claude-haiku-4-5-20251001"
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── Consolidation ─────────────────────────────────────────────────────
|
|
87
|
+
consolidate_default_num_records: int | str = "all"
|
|
88
|
+
consolidate_default_method: str = "LIFO"
|
|
89
|
+
|
|
90
|
+
# ── Embedding function ─────────────────────────────────────────────────
|
|
91
|
+
# None (default) → Chroma's bundled ONNX model (downloads on first use).
|
|
92
|
+
# "hash" → offline deterministic hash embedding (tests/CI only).
|
|
93
|
+
# Any chromadb EmbeddingFunction subclass → custom.
|
|
94
|
+
embedding_function: object = None
|
|
95
|
+
|
|
96
|
+
# Minimum cluster size before promoting (orphaned records use
|
|
97
|
+
# affectability; clustered records bypass this gate).
|
|
98
|
+
min_cluster_size: int = 2
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
if self.dedup_delete_threshold >= self.branch_threshold:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"dedup_delete_threshold ({self.dedup_delete_threshold}) must be "
|
|
104
|
+
f"strictly less than branch_threshold ({self.branch_threshold})"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Module-level default — override by passing Config() to Engine.
|
|
109
|
+
DEFAULT_CONFIG = Config()
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLMConnector abstract base class.
|
|
3
|
+
|
|
4
|
+
The connector's ONLY job is article synthesis — generative completion.
|
|
5
|
+
Embeddings are always Chroma-native and never go through this connector.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLMConnector(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Single-method interface for generative synthesis.
|
|
15
|
+
|
|
16
|
+
Used in exactly one place in the worker:
|
|
17
|
+
- Reconsolidation-with-new-signal: rewrite an existing injected article
|
|
18
|
+
blended with fresh context.
|
|
19
|
+
|
|
20
|
+
All other worker operations (clustering, affectability arithmetic,
|
|
21
|
+
the reconsolidation gate) are LLM-free.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def complete(self, prompt: str, **opts) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Run a completion against the configured LLM.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
prompt: The full synthesis prompt (already assembled by the worker).
|
|
31
|
+
**opts: Optional provider-specific overrides (max_tokens, temperature…).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The generated text string.
|
|
35
|
+
"""
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote LLM connector — default synthesis backend.
|
|
3
|
+
|
|
4
|
+
Supports OpenAI and Anthropic. Auto-detects available keys.
|
|
5
|
+
Provider resolution order when llm_provider == "auto":
|
|
6
|
+
1. Use OpenAI if OPENAI_API_KEY is set.
|
|
7
|
+
2. Fall back to Anthropic if ANTHROPIC_API_KEY is set.
|
|
8
|
+
3. Raise RuntimeError if neither key is configured.
|
|
9
|
+
|
|
10
|
+
Models (from README):
|
|
11
|
+
OpenAI: gpt-4o-mini (default; gpt-5.4-nano when available)
|
|
12
|
+
Anthropic: claude-haiku-4-5-20251001
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from etchmem.connector.base import LLMConnector
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RemoteConnector(LLMConnector):
|
|
23
|
+
"""
|
|
24
|
+
API-backed connector for OpenAI and Anthropic.
|
|
25
|
+
|
|
26
|
+
The connector is intentionally thin — it wraps one method (complete)
|
|
27
|
+
and delegates everything else to the provider SDK.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
provider: str = "auto", # "openai" | "anthropic" | "auto"
|
|
33
|
+
openai_api_key: str | None = None,
|
|
34
|
+
openai_model: str = "gpt-4o-mini",
|
|
35
|
+
anthropic_api_key: str | None = None,
|
|
36
|
+
anthropic_model: str = "claude-haiku-4-5-20251001",
|
|
37
|
+
) -> None:
|
|
38
|
+
self._openai_key = openai_api_key
|
|
39
|
+
self._openai_model = openai_model
|
|
40
|
+
self._anthropic_key = anthropic_api_key
|
|
41
|
+
self._anthropic_model = anthropic_model
|
|
42
|
+
self._provider = self._resolve_provider(provider)
|
|
43
|
+
|
|
44
|
+
def _resolve_provider(self, requested: str) -> str:
|
|
45
|
+
if requested == "auto":
|
|
46
|
+
if self._openai_key:
|
|
47
|
+
return "openai"
|
|
48
|
+
if self._anthropic_key:
|
|
49
|
+
return "anthropic"
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"skillmem: no LLM API key configured. "
|
|
52
|
+
"Set OPENAI_API_KEY or ANTHROPIC_API_KEY (or pass them to Config)."
|
|
53
|
+
)
|
|
54
|
+
if requested == "openai" and not self._openai_key:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"skillmem: llm_provider='openai' but OPENAI_API_KEY is not set."
|
|
57
|
+
)
|
|
58
|
+
if requested == "anthropic" and not self._anthropic_key:
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
"skillmem: llm_provider='anthropic' but ANTHROPIC_API_KEY is not set."
|
|
61
|
+
)
|
|
62
|
+
return requested
|
|
63
|
+
|
|
64
|
+
def complete(self, prompt: str, **opts: Any) -> str:
|
|
65
|
+
"""Run synthesis completion against the configured provider."""
|
|
66
|
+
if self._provider == "openai":
|
|
67
|
+
return self._complete_openai(prompt, **opts)
|
|
68
|
+
return self._complete_anthropic(prompt, **opts)
|
|
69
|
+
|
|
70
|
+
def _complete_openai(self, prompt: str, **opts: Any) -> str:
|
|
71
|
+
try:
|
|
72
|
+
from openai import OpenAI # type: ignore[import]
|
|
73
|
+
except ImportError as e:
|
|
74
|
+
raise ImportError("skillmem: 'openai' package is not installed.") from e
|
|
75
|
+
|
|
76
|
+
client = OpenAI(api_key=self._openai_key)
|
|
77
|
+
max_tokens = opts.pop("max_tokens", 2048)
|
|
78
|
+
temperature = opts.pop("temperature", 0.3)
|
|
79
|
+
|
|
80
|
+
response = client.chat.completions.create(
|
|
81
|
+
model=self._openai_model,
|
|
82
|
+
messages=[{"role": "user", "content": prompt}],
|
|
83
|
+
max_tokens=max_tokens,
|
|
84
|
+
temperature=temperature,
|
|
85
|
+
**opts,
|
|
86
|
+
)
|
|
87
|
+
return response.choices[0].message.content or ""
|
|
88
|
+
|
|
89
|
+
def _complete_anthropic(self, prompt: str, **opts: Any) -> str:
|
|
90
|
+
try:
|
|
91
|
+
import anthropic # type: ignore[import]
|
|
92
|
+
except ImportError as e:
|
|
93
|
+
raise ImportError("skillmem: 'anthropic' package is not installed.") from e
|
|
94
|
+
|
|
95
|
+
client = anthropic.Anthropic(api_key=self._anthropic_key)
|
|
96
|
+
max_tokens = opts.pop("max_tokens", 2048)
|
|
97
|
+
|
|
98
|
+
message = client.messages.create(
|
|
99
|
+
model=self._anthropic_model,
|
|
100
|
+
max_tokens=max_tokens,
|
|
101
|
+
messages=[{"role": "user", "content": prompt}],
|
|
102
|
+
**opts,
|
|
103
|
+
)
|
|
104
|
+
# Extract text from the first text block
|
|
105
|
+
for block in message.content:
|
|
106
|
+
if hasattr(block, "text"):
|
|
107
|
+
return block.text
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── Convenience factory ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def build_connector(config: "Any") -> RemoteConnector:
|
|
114
|
+
"""Build a RemoteConnector from a Config object."""
|
|
115
|
+
return RemoteConnector(
|
|
116
|
+
provider=config.llm_provider,
|
|
117
|
+
openai_api_key=config.openai_api_key,
|
|
118
|
+
openai_model=config.openai_model,
|
|
119
|
+
anthropic_api_key=config.anthropic_api_key,
|
|
120
|
+
anthropic_model=config.anthropic_model,
|
|
121
|
+
)
|
|
File without changes
|