era-memory 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.
- era_memory/__init__.py +49 -0
- era_memory/__main__.py +152 -0
- era_memory/adapters/__init__.py +1 -0
- era_memory/adapters/auth.py +24 -0
- era_memory/adapters/fastembed/__init__.py +168 -0
- era_memory/adapters/memory/__init__.py +307 -0
- era_memory/adapters/openai/__init__.py +82 -0
- era_memory/adapters/postgres/__init__.py +323 -0
- era_memory/adapters/sqlite/__init__.py +328 -0
- era_memory/app.py +156 -0
- era_memory/config.py +65 -0
- era_memory/core/__init__.py +0 -0
- era_memory/core/logic/__init__.py +0 -0
- era_memory/core/logic/dedup.py +49 -0
- era_memory/core/logic/entropy.py +37 -0
- era_memory/core/logic/envelope.py +50 -0
- era_memory/core/logic/matryoshka.py +24 -0
- era_memory/core/logic/rrf.py +54 -0
- era_memory/core/orchestration.py +91 -0
- era_memory/core/pipeline.py +80 -0
- era_memory/core/search.py +105 -0
- era_memory/embedders.py +74 -0
- era_memory/errors.py +34 -0
- era_memory/memory.py +106 -0
- era_memory/models.py +150 -0
- era_memory/ports/__init__.py +28 -0
- era_memory/ports/auth.py +11 -0
- era_memory/ports/blob_store.py +17 -0
- era_memory/ports/embedder.py +19 -0
- era_memory/ports/extractor.py +15 -0
- era_memory/ports/kms.py +26 -0
- era_memory/ports/queue.py +24 -0
- era_memory/ports/record_store.py +47 -0
- era_memory/ports/telemetry.py +18 -0
- era_memory/ports/vector_store.py +48 -0
- era_memory/wiring.py +225 -0
- era_memory-0.1.0.dist-info/METADATA +220 -0
- era_memory-0.1.0.dist-info/RECORD +41 -0
- era_memory-0.1.0.dist-info/WHEEL +4 -0
- era_memory-0.1.0.dist-info/entry_points.txt +2 -0
- era_memory-0.1.0.dist-info/licenses/LICENSE +201 -0
era_memory/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""era-memory: portable, tier-selected memory system (ports & adapters).
|
|
2
|
+
|
|
3
|
+
Top-level convenience exports. The composition root (``build_memory`` /
|
|
4
|
+
``build_memory_async``) and the public data models are importable directly:
|
|
5
|
+
|
|
6
|
+
from era_memory import build_memory, MemoryRecord, SearchRequest
|
|
7
|
+
|
|
8
|
+
Backend-specific adapters (SQLite, Postgres, OpenAI-compatible embedder, …) live under
|
|
9
|
+
``era_memory.adapters.*`` and are imported lazily so the base package keeps zero
|
|
10
|
+
third-party dependencies — import them explicitly only when you use that backend.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .config import Settings
|
|
14
|
+
from .memory import Memory
|
|
15
|
+
from .models import (
|
|
16
|
+
ExtractedMemory,
|
|
17
|
+
ExtractionResult,
|
|
18
|
+
MemoryRecord,
|
|
19
|
+
MemoryType,
|
|
20
|
+
SearchFilters,
|
|
21
|
+
SearchRequest,
|
|
22
|
+
SearchResponse,
|
|
23
|
+
SearchResult,
|
|
24
|
+
SearchStrategy,
|
|
25
|
+
SessionPayload,
|
|
26
|
+
VectorRecord,
|
|
27
|
+
)
|
|
28
|
+
from .wiring import build_memory, build_memory_async
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Settings",
|
|
34
|
+
"Memory",
|
|
35
|
+
"build_memory",
|
|
36
|
+
"build_memory_async",
|
|
37
|
+
"MemoryRecord",
|
|
38
|
+
"MemoryType",
|
|
39
|
+
"SearchFilters",
|
|
40
|
+
"SearchRequest",
|
|
41
|
+
"SearchResponse",
|
|
42
|
+
"SearchResult",
|
|
43
|
+
"SearchStrategy",
|
|
44
|
+
"SessionPayload",
|
|
45
|
+
"ExtractedMemory",
|
|
46
|
+
"ExtractionResult",
|
|
47
|
+
"VectorRecord",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
era_memory/__main__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``era-memory`` command-line entry point.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
setup Interactively download a local embedding model from Hugging Face and cache it,
|
|
6
|
+
so era-memory does semantic search offline with no endpoint.
|
|
7
|
+
status Show the current embedder situation (endpoint / cached models / fastembed).
|
|
8
|
+
|
|
9
|
+
This is the *only* place that initiates a download by default (plus the explicit
|
|
10
|
+
``build_memory(embedder="auto")``). A bare import or ``build_memory()`` never downloads.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _print_status() -> None:
|
|
21
|
+
from .adapters.fastembed import (
|
|
22
|
+
SUPPORTED_MODELS,
|
|
23
|
+
default_cache_dir,
|
|
24
|
+
fastembed_available,
|
|
25
|
+
is_cached,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
url = os.environ.get("MEMORY_EMBEDDING_URL")
|
|
29
|
+
print("era-memory embedder status")
|
|
30
|
+
print("-" * 40)
|
|
31
|
+
print(f"Endpoint (MEMORY_EMBEDDING_URL): {url or '(not set)'}")
|
|
32
|
+
print(f"fastembed installed: {fastembed_available()}")
|
|
33
|
+
print(f"Model cache dir: {default_cache_dir()}")
|
|
34
|
+
print("Local models:")
|
|
35
|
+
any_cached = False
|
|
36
|
+
for spec in SUPPORTED_MODELS.values():
|
|
37
|
+
cached = is_cached(spec)
|
|
38
|
+
any_cached = any_cached or cached
|
|
39
|
+
mark = "✓ cached" if cached else "— not downloaded"
|
|
40
|
+
print(f" {spec.key:<12} {spec.fastembed_name:<40} dim={spec.dimensions:<5} {mark}")
|
|
41
|
+
if not url and not any_cached:
|
|
42
|
+
print("\nNo real embedder configured → search would use the non-semantic dev embedder.")
|
|
43
|
+
print("Run `era-memory setup` to download one.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_setup(args: argparse.Namespace) -> int:
|
|
47
|
+
from .adapters.fastembed import (
|
|
48
|
+
DEFAULT_MODEL_KEY,
|
|
49
|
+
SUPPORTED_MODELS,
|
|
50
|
+
default_cache_dir,
|
|
51
|
+
download_model,
|
|
52
|
+
fastembed_available,
|
|
53
|
+
first_cached_model,
|
|
54
|
+
is_cached,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if os.environ.get("MEMORY_EMBEDDING_URL"):
|
|
58
|
+
print("MEMORY_EMBEDDING_URL is set — era-memory will use that endpoint. Nothing to do.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if not fastembed_available():
|
|
62
|
+
print(
|
|
63
|
+
"The local embedder needs the [localembed] extra.\n"
|
|
64
|
+
"Install it with:\n\n pip install 'era-memory[localembed]'\n",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
return 1
|
|
68
|
+
|
|
69
|
+
already = first_cached_model()
|
|
70
|
+
if already is not None and not args.force:
|
|
71
|
+
print(f"A local model is already cached: {already.fastembed_name} (dim {already.dimensions}).")
|
|
72
|
+
print("Use it with build_memory(...) directly, or pass --force to add another.")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
# Resolve which model to fetch.
|
|
76
|
+
model_key = args.model
|
|
77
|
+
if model_key is None:
|
|
78
|
+
if not sys.stdin.isatty() or args.yes:
|
|
79
|
+
model_key = DEFAULT_MODEL_KEY # non-interactive default
|
|
80
|
+
else:
|
|
81
|
+
model_key = _prompt_model_choice()
|
|
82
|
+
if model_key is None:
|
|
83
|
+
print("Aborted.")
|
|
84
|
+
return 1
|
|
85
|
+
if model_key not in SUPPORTED_MODELS:
|
|
86
|
+
print(f"Unknown model '{model_key}'. Choices: {', '.join(SUPPORTED_MODELS)}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
spec = SUPPORTED_MODELS[model_key]
|
|
90
|
+
if is_cached(spec) and not args.force:
|
|
91
|
+
print(f"{spec.fastembed_name} is already cached (dim {spec.dimensions}).")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if sys.stdin.isatty() and not args.yes:
|
|
95
|
+
ans = input(
|
|
96
|
+
f"Download {spec.fastembed_name} (~{spec.approx_size_mb} MB, {spec.license}, "
|
|
97
|
+
f"dim {spec.dimensions}) from Hugging Face? [Y/n] "
|
|
98
|
+
).strip().lower()
|
|
99
|
+
if ans not in ("", "y", "yes"):
|
|
100
|
+
print("Aborted.")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
print(f"Downloading {spec.fastembed_name} → {default_cache_dir()} …")
|
|
104
|
+
download_model(spec)
|
|
105
|
+
print(
|
|
106
|
+
f"Done. Cached '{spec.key}' (dim {spec.dimensions}).\n"
|
|
107
|
+
"era-memory will now use it automatically — e.g. build_memory(tier=0, db_path='memory.db')."
|
|
108
|
+
)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _prompt_model_choice() -> "str | None":
|
|
113
|
+
from .adapters.fastembed import DEFAULT_MODEL_KEY, SUPPORTED_MODELS
|
|
114
|
+
|
|
115
|
+
keys = list(SUPPORTED_MODELS)
|
|
116
|
+
print("Choose a local embedding model to download:")
|
|
117
|
+
for i, key in enumerate(keys, 1):
|
|
118
|
+
spec = SUPPORTED_MODELS[key]
|
|
119
|
+
default = " (default)" if key == DEFAULT_MODEL_KEY else ""
|
|
120
|
+
print(
|
|
121
|
+
f" {i}) {key}{default}: {spec.fastembed_name} — dim {spec.dimensions}, "
|
|
122
|
+
f"~{spec.approx_size_mb} MB, {spec.license}"
|
|
123
|
+
)
|
|
124
|
+
raw = input(f"Enter 1-{len(keys)} [default {keys.index(DEFAULT_MODEL_KEY) + 1}]: ").strip()
|
|
125
|
+
if raw == "":
|
|
126
|
+
return DEFAULT_MODEL_KEY
|
|
127
|
+
if raw.isdigit() and 1 <= int(raw) <= len(keys):
|
|
128
|
+
return keys[int(raw) - 1]
|
|
129
|
+
if raw in SUPPORTED_MODELS:
|
|
130
|
+
return raw
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
135
|
+
parser = argparse.ArgumentParser(prog="era-memory", description="era-memory utilities")
|
|
136
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
137
|
+
|
|
138
|
+
p_setup = sub.add_parser("setup", help="download & cache a local embedding model")
|
|
139
|
+
p_setup.add_argument("--model", choices=["bge-small", "mxbai-large"], help="skip the prompt")
|
|
140
|
+
p_setup.add_argument("--yes", "-y", action="store_true", help="assume yes (non-interactive)")
|
|
141
|
+
p_setup.add_argument("--force", action="store_true", help="download even if one is cached")
|
|
142
|
+
p_setup.set_defaults(func=_cmd_setup)
|
|
143
|
+
|
|
144
|
+
p_status = sub.add_parser("status", help="show embedder configuration")
|
|
145
|
+
p_status.set_defaults(func=lambda _a: (_print_status() or 0))
|
|
146
|
+
|
|
147
|
+
args = parser.parse_args(argv)
|
|
148
|
+
return args.func(args)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters: concrete backends behind the ports. ``memory`` is the in-process reference set."""
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Static bearer-token Auth adapter (Tier 1). Drop-in for the Oathkeeper-JWT Auth port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..ports import Auth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BearerAuth(Auth):
|
|
12
|
+
def __init__(self, token: str, *, user_header: str = "X-User-Id") -> None:
|
|
13
|
+
self._token = token
|
|
14
|
+
self._user_header = user_header
|
|
15
|
+
|
|
16
|
+
async def authenticate(self, headers: dict[str, str]) -> Optional[str]:
|
|
17
|
+
# HTTP header names are case-insensitive (Starlette lower-cases them).
|
|
18
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
19
|
+
auth = lower.get("authorization", "")
|
|
20
|
+
if not auth.startswith("Bearer "):
|
|
21
|
+
return None
|
|
22
|
+
if not hmac.compare_digest(auth[7:], self._token):
|
|
23
|
+
return None
|
|
24
|
+
return lower.get(self._user_header.lower())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Offline local embedder backed by ``fastembed`` (ONNX, CPU-only).
|
|
3
|
+
|
|
4
|
+
Downloads a small embedding model straight from Hugging Face on demand, caches it under a
|
|
5
|
+
known directory, and serves embeddings in-process — no embedding endpoint, GPU, or API key.
|
|
6
|
+
This is the "real" embedder for the offline/laptop tier; the OpenAI-compatible embedder
|
|
7
|
+
remains the path for hosted endpoints.
|
|
8
|
+
|
|
9
|
+
Needs the ``[localembed]`` extra (``fastembed``). The download is **never** triggered by
|
|
10
|
+
import or by a bare ``build_memory()`` — only by ``era-memory setup`` / ``build_memory(
|
|
11
|
+
embedder="auto")`` (explicit opt-in). See ``era_memory.embedders`` for the resolver and
|
|
12
|
+
``docs/adr/0001`` for the dimension contract.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ...core.logic.matryoshka import truncate_and_normalize
|
|
25
|
+
from ...ports import Embedder
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ModelSpec:
|
|
30
|
+
"""A downloadable local model: short key, its HF/fastembed name, dim, and footprint."""
|
|
31
|
+
|
|
32
|
+
key: str
|
|
33
|
+
fastembed_name: str
|
|
34
|
+
dimensions: int
|
|
35
|
+
approx_size_mb: int
|
|
36
|
+
license: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# The curated set offered by `era-memory setup`. Both are fastembed-supported and
|
|
40
|
+
# redistributable; keep this list small and verified rather than exposing all of fastembed.
|
|
41
|
+
SUPPORTED_MODELS: dict[str, ModelSpec] = {
|
|
42
|
+
"bge-small": ModelSpec(
|
|
43
|
+
key="bge-small",
|
|
44
|
+
fastembed_name="BAAI/bge-small-en-v1.5",
|
|
45
|
+
dimensions=384,
|
|
46
|
+
approx_size_mb=90,
|
|
47
|
+
license="MIT",
|
|
48
|
+
),
|
|
49
|
+
"mxbai-large": ModelSpec(
|
|
50
|
+
key="mxbai-large",
|
|
51
|
+
fastembed_name="mixedbread-ai/mxbai-embed-large-v1",
|
|
52
|
+
dimensions=1024,
|
|
53
|
+
approx_size_mb=640,
|
|
54
|
+
license="Apache-2.0",
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
DEFAULT_MODEL_KEY = "bge-small"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_cache_dir() -> Path:
|
|
62
|
+
"""Where models + readiness sentinels live. Overridable via ERA_MEMORY_MODEL_DIR."""
|
|
63
|
+
env = os.environ.get("ERA_MEMORY_MODEL_DIR")
|
|
64
|
+
base = Path(env).expanduser() if env else Path.home() / ".cache" / "era-memory" / "models"
|
|
65
|
+
return base
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sentinel(cache_dir: Path, spec: ModelSpec) -> Path:
|
|
69
|
+
return cache_dir / f"{spec.key}.ready"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_cached(spec: ModelSpec, cache_dir: Optional[Path] = None) -> bool:
|
|
73
|
+
"""True iff this model was already downloaded by us (readiness sentinel present).
|
|
74
|
+
|
|
75
|
+
Cheap and side-effect-free — the resolver uses it to decide *without* hitting the network.
|
|
76
|
+
"""
|
|
77
|
+
cache_dir = cache_dir or default_cache_dir()
|
|
78
|
+
return _sentinel(cache_dir, spec).exists()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def first_cached_model(cache_dir: Optional[Path] = None) -> Optional[ModelSpec]:
|
|
82
|
+
cache_dir = cache_dir or default_cache_dir()
|
|
83
|
+
for spec in SUPPORTED_MODELS.values():
|
|
84
|
+
if is_cached(spec, cache_dir):
|
|
85
|
+
return spec
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fastembed_available() -> bool:
|
|
90
|
+
try:
|
|
91
|
+
import fastembed # noqa: F401
|
|
92
|
+
except ImportError:
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _require_fastembed():
|
|
98
|
+
try:
|
|
99
|
+
from fastembed import TextEmbedding
|
|
100
|
+
except ImportError as e:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"The local embedder needs the [localembed] extra: "
|
|
103
|
+
"pip install 'era-memory[localembed]'"
|
|
104
|
+
) from e
|
|
105
|
+
return TextEmbedding
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def download_model(spec: ModelSpec, cache_dir: Optional[Path] = None) -> Path:
|
|
109
|
+
"""Fetch ``spec`` from Hugging Face into the cache and write a readiness sentinel.
|
|
110
|
+
|
|
111
|
+
Materializes the model by running one embed so the ONNX weights are fully fetched, then
|
|
112
|
+
records ``<key>.ready`` so :func:`is_cached` can detect it offline afterwards.
|
|
113
|
+
"""
|
|
114
|
+
cache_dir = cache_dir or default_cache_dir()
|
|
115
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
TextEmbedding = _require_fastembed()
|
|
117
|
+
model = TextEmbedding(model_name=spec.fastembed_name, cache_dir=str(cache_dir))
|
|
118
|
+
# Force a real forward pass so the download is complete before we mark it ready.
|
|
119
|
+
list(model.embed(["warmup"]))
|
|
120
|
+
_sentinel(cache_dir, spec).write_text(
|
|
121
|
+
json.dumps({"model": spec.fastembed_name, "dim": spec.dimensions})
|
|
122
|
+
)
|
|
123
|
+
return cache_dir
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class FastEmbedEmbedder(Embedder):
|
|
127
|
+
"""Serves a cached fastembed model. Construction is cheap; the model loads on first embed."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
spec: ModelSpec,
|
|
132
|
+
*,
|
|
133
|
+
cache_dir: Optional[Path] = None,
|
|
134
|
+
dimensions: Optional[int] = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
self._spec = spec
|
|
137
|
+
self._cache_dir = cache_dir or default_cache_dir()
|
|
138
|
+
if dimensions is not None and dimensions > spec.dimensions:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"requested dim {dimensions} exceeds {spec.fastembed_name}'s native "
|
|
141
|
+
f"{spec.dimensions} (Matryoshka truncates down, never up)"
|
|
142
|
+
)
|
|
143
|
+
self._dim = dimensions or spec.dimensions
|
|
144
|
+
self._model = None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def dimensions(self) -> int:
|
|
148
|
+
return self._dim
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def model_id(self) -> str:
|
|
152
|
+
return self._spec.fastembed_name
|
|
153
|
+
|
|
154
|
+
def _get_model(self):
|
|
155
|
+
if self._model is None:
|
|
156
|
+
TextEmbedding = _require_fastembed()
|
|
157
|
+
self._model = TextEmbedding(
|
|
158
|
+
model_name=self._spec.fastembed_name, cache_dir=str(self._cache_dir)
|
|
159
|
+
)
|
|
160
|
+
return self._model
|
|
161
|
+
|
|
162
|
+
def _embed_sync(self, texts: list[str]) -> list[list[float]]:
|
|
163
|
+
model = self._get_model()
|
|
164
|
+
return [truncate_and_normalize(vec.tolist(), self._dim) for vec in model.embed(texts)]
|
|
165
|
+
|
|
166
|
+
async def embed(self, texts: list[str]) -> list[list[float]]:
|
|
167
|
+
# fastembed is synchronous + CPU-bound; keep the event loop responsive.
|
|
168
|
+
return await asyncio.to_thread(self._embed_sync, texts)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory reference adapters for all nine ports.
|
|
3
|
+
|
|
4
|
+
Purpose: lock the port contracts (via the conformance suite) before any infrastructure,
|
|
5
|
+
and provide a zero-dependency tier for tests and quick embedding-in-an-app. The RecordStore
|
|
6
|
+
and VectorStore here are SEPARATE stores, so M0 exercises the split-store (Tier-2-shaped)
|
|
7
|
+
dual-write path; the collapsed single-store path arrives with the SQLite adapter (M1).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from ...core.logic.dedup import cosine_similarity
|
|
20
|
+
from ...core.logic.entropy import entropy_score
|
|
21
|
+
from ...core.logic.matryoshka import l2_normalize
|
|
22
|
+
from ...errors import VectorStoreWriteError
|
|
23
|
+
from ...models import (
|
|
24
|
+
ExtractedMemory,
|
|
25
|
+
ExtractionResult,
|
|
26
|
+
MemoryRecord,
|
|
27
|
+
MemoryType,
|
|
28
|
+
SearchFilters,
|
|
29
|
+
SessionPayload,
|
|
30
|
+
VectorRecord,
|
|
31
|
+
)
|
|
32
|
+
from ...ports import (
|
|
33
|
+
Auth,
|
|
34
|
+
BlobStore,
|
|
35
|
+
Embedder,
|
|
36
|
+
Extractor,
|
|
37
|
+
KMS,
|
|
38
|
+
Queue,
|
|
39
|
+
RecordStore,
|
|
40
|
+
Telemetry,
|
|
41
|
+
UnitOfWork,
|
|
42
|
+
VectorStore,
|
|
43
|
+
)
|
|
44
|
+
from ...ports.queue import Consumer
|
|
45
|
+
|
|
46
|
+
_TOKEN = re.compile(r"\w+")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _matches(rec: MemoryRecord, f: SearchFilters) -> bool:
|
|
50
|
+
if f.memory_type is not None and rec.memory_type != f.memory_type:
|
|
51
|
+
return False
|
|
52
|
+
if f.experience_id is not None and (rec.experience_id or "") != f.experience_id:
|
|
53
|
+
return False
|
|
54
|
+
if f.created_after is not None and rec.created_at < f.created_after:
|
|
55
|
+
return False
|
|
56
|
+
if f.created_before is not None and rec.created_at > f.created_before:
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- RecordStore
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _MemUoW(UnitOfWork):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InMemoryRecordStore(RecordStore):
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._by_user: dict[str, dict[str, MemoryRecord]] = {}
|
|
71
|
+
self._hash_index: dict[tuple[str, str], str] = {}
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def unit_of_work(self):
|
|
75
|
+
snap_users = {u: dict(d) for u, d in self._by_user.items()}
|
|
76
|
+
snap_hash = dict(self._hash_index)
|
|
77
|
+
try:
|
|
78
|
+
yield _MemUoW()
|
|
79
|
+
except BaseException:
|
|
80
|
+
self._by_user = snap_users
|
|
81
|
+
self._hash_index = snap_hash
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
async def insert_memory(self, uow, record):
|
|
85
|
+
users = self._by_user.setdefault(record.user_id, {})
|
|
86
|
+
if record.content_hash:
|
|
87
|
+
key = (record.user_id, record.content_hash)
|
|
88
|
+
existing = self._hash_index.get(key)
|
|
89
|
+
if existing is not None:
|
|
90
|
+
return users[existing], False
|
|
91
|
+
users[record.id] = record
|
|
92
|
+
self._hash_index[key] = record.id
|
|
93
|
+
return record, True
|
|
94
|
+
users[record.id] = record
|
|
95
|
+
return record, True
|
|
96
|
+
|
|
97
|
+
async def fetch_by_ids(self, user_id, ids):
|
|
98
|
+
users = self._by_user.get(user_id, {})
|
|
99
|
+
out = [users[i] for i in ids if i in users and users[i].status == "active"]
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
async def lexical_search(self, user_id, query, filters, limit):
|
|
103
|
+
users = self._by_user.get(user_id, {})
|
|
104
|
+
terms = set(_TOKEN.findall(query.lower()))
|
|
105
|
+
scored: list[tuple[str, float]] = []
|
|
106
|
+
for rec in users.values():
|
|
107
|
+
if rec.status != "active" or not _matches(rec, filters):
|
|
108
|
+
continue
|
|
109
|
+
content_terms = _TOKEN.findall(rec.content.lower())
|
|
110
|
+
if not content_terms:
|
|
111
|
+
continue
|
|
112
|
+
overlap = sum(1 for t in content_terms if t in terms)
|
|
113
|
+
if overlap > 0:
|
|
114
|
+
scored.append((rec.id, float(overlap)))
|
|
115
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
116
|
+
return scored[:limit]
|
|
117
|
+
|
|
118
|
+
async def soft_delete(self, user_id, memory_id):
|
|
119
|
+
rec = self._by_user.get(user_id, {}).get(memory_id)
|
|
120
|
+
if rec is None or rec.status != "active":
|
|
121
|
+
return False
|
|
122
|
+
rec.status = "archived"
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------- VectorStore
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class InMemoryVectorStore(VectorStore):
|
|
130
|
+
def __init__(self) -> None:
|
|
131
|
+
self._vecs: dict[str, VectorRecord] = {}
|
|
132
|
+
|
|
133
|
+
async def insert(self, records):
|
|
134
|
+
for r in records:
|
|
135
|
+
if not r.embedding:
|
|
136
|
+
raise VectorStoreWriteError(f"empty embedding for {r.id}")
|
|
137
|
+
self._vecs[r.id] = r
|
|
138
|
+
return len(records)
|
|
139
|
+
|
|
140
|
+
async def search(self, user_id, embedding, limit, filters):
|
|
141
|
+
scored: list[tuple[str, float]] = []
|
|
142
|
+
for r in self._vecs.values():
|
|
143
|
+
if r.user_id != user_id:
|
|
144
|
+
continue
|
|
145
|
+
if filters.memory_type is not None and r.memory_type != filters.memory_type:
|
|
146
|
+
continue
|
|
147
|
+
if filters.experience_id is not None and r.experience_id != filters.experience_id:
|
|
148
|
+
continue
|
|
149
|
+
sim = cosine_similarity(embedding, r.embedding)
|
|
150
|
+
scored.append((r.id, sim))
|
|
151
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
152
|
+
return scored[:limit]
|
|
153
|
+
|
|
154
|
+
async def delete(self, ids):
|
|
155
|
+
n = 0
|
|
156
|
+
for i in ids:
|
|
157
|
+
if i in self._vecs:
|
|
158
|
+
del self._vecs[i]
|
|
159
|
+
n += 1
|
|
160
|
+
return n
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ----------------------------------------------------------------------------- Embedder
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class InMemoryEmbedder(Embedder):
|
|
167
|
+
"""Deterministic hashed bag-of-words embedding. Shared words -> higher cosine."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, dim: int = 64, model_id: str = "in-memory-hash") -> None:
|
|
170
|
+
self._dim = dim
|
|
171
|
+
self._model = model_id
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def dimensions(self) -> int:
|
|
175
|
+
return self._dim
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def model_id(self) -> str:
|
|
179
|
+
return self._model
|
|
180
|
+
|
|
181
|
+
async def embed(self, texts):
|
|
182
|
+
return [self._embed_one(t) for t in texts]
|
|
183
|
+
|
|
184
|
+
def _embed_one(self, text: str) -> list[float]:
|
|
185
|
+
vec = [0.0] * self._dim
|
|
186
|
+
for tok in _TOKEN.findall(text.lower()):
|
|
187
|
+
h = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16)
|
|
188
|
+
vec[h % self._dim] += 1.0
|
|
189
|
+
return l2_normalize(vec)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# -------------------------------------------------------------------------------- Queue
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class InProcessQueue(Queue):
|
|
196
|
+
"""Runs the consumer synchronously on submit — no Redis, no durability."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, consumer: Consumer) -> None:
|
|
199
|
+
self._consumer = consumer
|
|
200
|
+
|
|
201
|
+
async def submit(self, payload: SessionPayload) -> None:
|
|
202
|
+
await self._consumer(payload)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------- Extractor
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class HeuristicExtractor(Extractor):
|
|
209
|
+
"""Offline, no-LLM: each substantive line of the conversation becomes a candidate."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, min_words: int = 3) -> None:
|
|
212
|
+
self._min_words = min_words
|
|
213
|
+
|
|
214
|
+
async def extract(self, payload):
|
|
215
|
+
memories: list[ExtractedMemory] = []
|
|
216
|
+
for line in payload.conversation.splitlines():
|
|
217
|
+
line = line.strip()
|
|
218
|
+
if len(line.split()) < self._min_words:
|
|
219
|
+
continue
|
|
220
|
+
memories.append(
|
|
221
|
+
ExtractedMemory(
|
|
222
|
+
content=line,
|
|
223
|
+
memory_type=MemoryType.EPISODE,
|
|
224
|
+
importance_score=min(1.0, max(0.1, entropy_score(line))),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return ExtractionResult(memories=memories)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------- BlobStore
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class InMemoryBlobStore(BlobStore):
|
|
234
|
+
def __init__(self) -> None:
|
|
235
|
+
self._blobs: dict[str, bytes] = {}
|
|
236
|
+
|
|
237
|
+
async def put(self, key, data):
|
|
238
|
+
self._blobs[key] = data
|
|
239
|
+
|
|
240
|
+
async def get(self, key):
|
|
241
|
+
return self._blobs.get(key)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------------- KMS
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class LocalKMS(KMS):
|
|
248
|
+
"""
|
|
249
|
+
Dependency-free local/dev KMS: HMAC-SHA256 keystream + HMAC tag (authenticated wrap).
|
|
250
|
+
Tier 0's real local KMS (M1) can swap to AESGCM via the [encryption] extra.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, master_key: Optional[bytes] = None) -> None:
|
|
254
|
+
self._mk = master_key or os.urandom(32)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def provider_name(self) -> str:
|
|
258
|
+
return "local"
|
|
259
|
+
|
|
260
|
+
async def generate_dek(self) -> bytes:
|
|
261
|
+
return os.urandom(32)
|
|
262
|
+
|
|
263
|
+
def _keystream(self, nonce: bytes, length: int) -> bytes:
|
|
264
|
+
out = b""
|
|
265
|
+
counter = 0
|
|
266
|
+
while len(out) < length:
|
|
267
|
+
out += hmac.new(
|
|
268
|
+
self._mk, nonce + counter.to_bytes(4, "big"), hashlib.sha256
|
|
269
|
+
).digest()
|
|
270
|
+
counter += 1
|
|
271
|
+
return out[:length]
|
|
272
|
+
|
|
273
|
+
async def wrap_dek(self, dek, aad):
|
|
274
|
+
nonce = os.urandom(16)
|
|
275
|
+
ks = self._keystream(nonce, len(dek))
|
|
276
|
+
ct = bytes(a ^ b for a, b in zip(dek, ks))
|
|
277
|
+
tag = hmac.new(self._mk, nonce + ct + aad.encode("utf-8"), hashlib.sha256).digest()
|
|
278
|
+
return nonce + tag + ct
|
|
279
|
+
|
|
280
|
+
async def unwrap_dek(self, wrapped, aad):
|
|
281
|
+
nonce, tag, ct = wrapped[:16], wrapped[16:48], wrapped[48:]
|
|
282
|
+
expected = hmac.new(self._mk, nonce + ct + aad.encode("utf-8"), hashlib.sha256).digest()
|
|
283
|
+
if not hmac.compare_digest(tag, expected):
|
|
284
|
+
raise ValueError("KMS unwrap authentication failure")
|
|
285
|
+
ks = self._keystream(nonce, len(ct))
|
|
286
|
+
return bytes(a ^ b for a, b in zip(ct, ks))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------------- Auth
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class NoopAuth(Auth):
|
|
293
|
+
"""Single-user / trusted-caller: returns X-User-Id header or a default."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, default_user: str = "local") -> None:
|
|
296
|
+
self._default = default_user
|
|
297
|
+
|
|
298
|
+
async def authenticate(self, headers):
|
|
299
|
+
return headers.get("X-User-Id", self._default)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------- Telemetry
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class NoopTelemetry(Telemetry):
|
|
306
|
+
def event(self, name, **fields):
|
|
307
|
+
return None
|