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.
Files changed (41) hide show
  1. era_memory/__init__.py +49 -0
  2. era_memory/__main__.py +152 -0
  3. era_memory/adapters/__init__.py +1 -0
  4. era_memory/adapters/auth.py +24 -0
  5. era_memory/adapters/fastembed/__init__.py +168 -0
  6. era_memory/adapters/memory/__init__.py +307 -0
  7. era_memory/adapters/openai/__init__.py +82 -0
  8. era_memory/adapters/postgres/__init__.py +323 -0
  9. era_memory/adapters/sqlite/__init__.py +328 -0
  10. era_memory/app.py +156 -0
  11. era_memory/config.py +65 -0
  12. era_memory/core/__init__.py +0 -0
  13. era_memory/core/logic/__init__.py +0 -0
  14. era_memory/core/logic/dedup.py +49 -0
  15. era_memory/core/logic/entropy.py +37 -0
  16. era_memory/core/logic/envelope.py +50 -0
  17. era_memory/core/logic/matryoshka.py +24 -0
  18. era_memory/core/logic/rrf.py +54 -0
  19. era_memory/core/orchestration.py +91 -0
  20. era_memory/core/pipeline.py +80 -0
  21. era_memory/core/search.py +105 -0
  22. era_memory/embedders.py +74 -0
  23. era_memory/errors.py +34 -0
  24. era_memory/memory.py +106 -0
  25. era_memory/models.py +150 -0
  26. era_memory/ports/__init__.py +28 -0
  27. era_memory/ports/auth.py +11 -0
  28. era_memory/ports/blob_store.py +17 -0
  29. era_memory/ports/embedder.py +19 -0
  30. era_memory/ports/extractor.py +15 -0
  31. era_memory/ports/kms.py +26 -0
  32. era_memory/ports/queue.py +24 -0
  33. era_memory/ports/record_store.py +47 -0
  34. era_memory/ports/telemetry.py +18 -0
  35. era_memory/ports/vector_store.py +48 -0
  36. era_memory/wiring.py +225 -0
  37. era_memory-0.1.0.dist-info/METADATA +220 -0
  38. era_memory-0.1.0.dist-info/RECORD +41 -0
  39. era_memory-0.1.0.dist-info/WHEEL +4 -0
  40. era_memory-0.1.0.dist-info/entry_points.txt +2 -0
  41. 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