Skip to content

Commit bf9a6b4

Browse files
groksrcclaude
andauthored
fix(core): resolve FastEmbed cache under data dir instead of /tmp (#743)
Signed-off-by: Drew Cain <groksrc@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 474100e commit bf9a6b4

5 files changed

Lines changed: 252 additions & 22 deletions

File tree

src/basic_memory/config.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ def _default_semantic_search_enabled() -> bool:
5050
)
5151

5252

53+
def resolve_data_dir() -> Path:
54+
"""Resolve the Basic Memory data directory.
55+
56+
Single source of truth for the per-user state directory. Honors
57+
``BASIC_MEMORY_CONFIG_DIR`` so each process/worktree can isolate config
58+
and database state; otherwise falls back to ``<user home>/.basic-memory``.
59+
60+
Cross-platform: ``Path.home()`` reads ``$HOME`` on POSIX and
61+
``%USERPROFILE%`` on Windows, so there's no need to check ``$HOME``
62+
explicitly here.
63+
"""
64+
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
65+
return Path(config_dir)
66+
return Path.home() / DATA_DIR_NAME
67+
68+
69+
def default_fastembed_cache_dir() -> str:
70+
"""Return the default cache directory used for FastEmbed model artifacts.
71+
72+
Resolution order:
73+
1. ``FASTEMBED_CACHE_PATH`` env var — honors FastEmbed's own convention
74+
so users who already configure it through the environment keep working.
75+
2. ``<basic-memory data dir>/fastembed_cache`` — the same stable,
76+
user-writable directory Basic Memory already uses for config and
77+
the default SQLite database. Honors ``BASIC_MEMORY_CONFIG_DIR``.
78+
79+
Why not ``tempfile.gettempdir()``?
80+
FastEmbed's own default is ``<system tmp>/fastembed_cache``, which is
81+
ephemeral in many sandboxed MCP runtimes (e.g. Codex CLI wipes /tmp
82+
between invocations). The model then disappears and every subsequent
83+
ONNX load raises ``NO_SUCHFILE``. Persisting the cache under the
84+
per-user data directory works identically on macOS, Linux, and Windows.
85+
"""
86+
if env_override := os.getenv("FASTEMBED_CACHE_PATH"):
87+
return env_override
88+
return str(resolve_data_dir() / "fastembed_cache")
89+
90+
5391
@dataclass
5492
class ProjectConfig:
5593
"""Configuration for a specific basic-memory project."""
@@ -222,7 +260,13 @@ def __init__(self, **data: Any) -> None: ...
222260
)
223261
semantic_embedding_cache_dir: str | None = Field(
224262
default=None,
225-
description="Optional cache directory for FastEmbed model artifacts.",
263+
description=(
264+
"Optional override for the FastEmbed model cache directory. "
265+
"When unset, Basic Memory resolves this at runtime to "
266+
"<basic-memory data dir>/fastembed_cache (or FASTEMBED_CACHE_PATH "
267+
"when that env var is set) so the model persists across runs "
268+
"without hardcoding a path into config.json."
269+
),
226270
)
227271
semantic_embedding_threads: int | None = Field(
228272
default=None,
@@ -709,11 +753,7 @@ def ensure_project_paths_exists(self) -> "BasicMemoryConfig": # pragma: no cove
709753
@property
710754
def data_dir_path(self) -> Path:
711755
"""Get app state directory for config and default SQLite database."""
712-
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
713-
return Path(config_dir)
714-
715-
home = os.getenv("HOME", Path.home())
716-
return Path(home) / DATA_DIR_NAME
756+
return resolve_data_dir()
717757

718758

719759
# Module-level cache for configuration
@@ -731,16 +771,7 @@ class ConfigManager:
731771

732772
def __init__(self) -> None:
733773
"""Initialize the configuration manager."""
734-
home = os.getenv("HOME", Path.home())
735-
if isinstance(home, str):
736-
home = Path(home)
737-
738-
# Allow override via environment variable
739-
if config_dir := os.getenv("BASIC_MEMORY_CONFIG_DIR"):
740-
self.config_dir = Path(config_dir)
741-
else:
742-
self.config_dir = home / DATA_DIR_NAME
743-
774+
self.config_dir = resolve_data_dir()
744775
self.config_file = self.config_dir / CONFIG_FILE_NAME
745776

746777
# Ensure config directory exists

src/basic_memory/repository/embedding_provider_factory.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from threading import Lock
55

6-
from basic_memory.config import BasicMemoryConfig
6+
from basic_memory.config import BasicMemoryConfig, default_fastembed_cache_dir
77
from basic_memory.repository.embedding_provider import EmbeddingProvider
88

99
type ProviderCacheKey = tuple[
@@ -12,7 +12,7 @@
1212
int | None,
1313
int,
1414
int,
15-
str | None,
15+
str,
1616
int | None,
1717
int | None,
1818
]
@@ -22,6 +22,20 @@
2222
_FASTEMBED_MAX_THREADS = 8
2323

2424

25+
def _resolve_cache_dir(app_config: BasicMemoryConfig) -> str:
26+
"""Resolve the effective FastEmbed cache dir for this config.
27+
28+
Uses an explicit ``is not None`` check — an empty string override from
29+
config or ``BASIC_MEMORY_SEMANTIC_EMBEDDING_CACHE_DIR`` is an invalid
30+
path, not a request to fall back to the default, and FastEmbed's error
31+
message is clearer than silently swapping in a different directory.
32+
"""
33+
configured = app_config.semantic_embedding_cache_dir
34+
if configured is not None:
35+
return configured
36+
return default_fastembed_cache_dir()
37+
38+
2539
def _available_cpu_count() -> int | None:
2640
"""Return the CPU budget available to this process when the runtime exposes it."""
2741
process_cpu_count = getattr(os, "process_cpu_count", None)
@@ -61,15 +75,20 @@ def _resolve_fastembed_runtime_knobs(
6175

6276

6377
def _provider_cache_key(app_config: BasicMemoryConfig) -> ProviderCacheKey:
64-
"""Build a stable cache key from provider-relevant semantic embedding config."""
78+
"""Build a stable cache key from provider-relevant semantic embedding config.
79+
80+
Uses the *resolved* cache dir — not the raw config field — so different
81+
FASTEMBED_CACHE_PATH values produce distinct cache keys even when the
82+
config field itself is unset.
83+
"""
6584
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
6685
return (
6786
app_config.semantic_embedding_provider.strip().lower(),
6887
app_config.semantic_embedding_model,
6988
app_config.semantic_embedding_dimensions,
7089
app_config.semantic_embedding_batch_size,
7190
app_config.semantic_embedding_request_concurrency,
72-
app_config.semantic_embedding_cache_dir,
91+
_resolve_cache_dir(app_config),
7392
resolved_threads,
7493
resolved_parallel,
7594
)
@@ -103,8 +122,12 @@ def create_embedding_provider(app_config: BasicMemoryConfig) -> EmbeddingProvide
103122
from basic_memory.repository.fastembed_provider import FastEmbedEmbeddingProvider
104123

105124
resolved_threads, resolved_parallel = _resolve_fastembed_runtime_knobs(app_config)
106-
if app_config.semantic_embedding_cache_dir is not None:
107-
extra_kwargs["cache_dir"] = app_config.semantic_embedding_cache_dir
125+
# Trigger: cache_dir is resolved rather than passed through directly.
126+
# Why: FastEmbed's own default caches to <system tmp>/fastembed_cache,
127+
# which disappears in sandboxed MCP runtimes (e.g. Codex CLI). See #741.
128+
# Outcome: always pass an explicit, user-writable cache dir so the ONNX
129+
# model persists across runs.
130+
extra_kwargs["cache_dir"] = _resolve_cache_dir(app_config)
108131
if resolved_threads is not None:
109132
extra_kwargs["threads"] = resolved_threads
110133
if resolved_parallel is not None:

test-int/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,13 @@ async def test_project(config_home, engine_factory) -> Project:
307307

308308
@pytest.fixture
309309
def config_home(tmp_path, monkeypatch) -> Path:
310+
# Patch both HOME and USERPROFILE so Path.home() returns the test dir on
311+
# every platform — Path.home() reads HOME on POSIX and USERPROFILE on
312+
# Windows, and ConfigManager.data_dir_path now goes through Path.home()
313+
# via resolve_data_dir(). Must mirror tests/conftest.py:config_home.
310314
monkeypatch.setenv("HOME", str(tmp_path))
315+
if os.name == "nt":
316+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
311317
# Set BASIC_MEMORY_HOME to the test directory
312318
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
313319
return tmp_path

tests/repository/test_openai_provider.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ def test_embedding_provider_factory_uses_provider_defaults_when_dimensions_not_s
248248

249249
def test_embedding_provider_factory_forwards_fastembed_runtime_knobs():
250250
"""Factory should forward FastEmbed runtime tuning config fields."""
251+
reset_embedding_provider_cache()
251252
config = BasicMemoryConfig(
252253
env="test",
253254
projects={"test-project": "/tmp/basic-memory-test"},
@@ -265,6 +266,66 @@ def test_embedding_provider_factory_forwards_fastembed_runtime_knobs():
265266
assert provider.parallel == 2
266267

267268

269+
def test_embedding_provider_factory_uses_default_cache_dir_when_unset(config_home, monkeypatch):
270+
"""Factory should pass the data-dir-relative default when cache_dir is None.
271+
272+
Legacy configs that carry an explicit ``semantic_embedding_cache_dir: null``
273+
must still get a user-writable cache path rather than letting FastEmbed fall
274+
back to ``<tmp>/fastembed_cache``. See #741.
275+
"""
276+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
277+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
278+
reset_embedding_provider_cache()
279+
280+
config = BasicMemoryConfig(
281+
env="test",
282+
projects={"test-project": str(config_home / "project")},
283+
default_project="test-project",
284+
semantic_search_enabled=True,
285+
semantic_embedding_provider="fastembed",
286+
semantic_embedding_cache_dir=None,
287+
)
288+
289+
provider = create_embedding_provider(config)
290+
assert isinstance(provider, FastEmbedEmbeddingProvider)
291+
expected = str(config_home / ".basic-memory" / "fastembed_cache")
292+
assert provider.cache_dir == expected
293+
294+
295+
def test_embedding_provider_factory_cache_key_reflects_resolved_cache_dir(
296+
config_home, tmp_path, monkeypatch
297+
):
298+
"""Changing FASTEMBED_CACHE_PATH must yield a distinct cached provider.
299+
300+
The provider cache key uses the *resolved* cache dir rather than the raw
301+
(nullable) config field, so env-driven path changes invalidate the cache
302+
instead of silently returning a stale provider pointing at the old path.
303+
"""
304+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
305+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
306+
reset_embedding_provider_cache()
307+
308+
base_kwargs = dict(
309+
env="test",
310+
projects={"test-project": str(config_home / "project")},
311+
default_project="test-project",
312+
semantic_search_enabled=True,
313+
semantic_embedding_provider="fastembed",
314+
semantic_embedding_cache_dir=None,
315+
)
316+
317+
provider_a = create_embedding_provider(BasicMemoryConfig(**base_kwargs))
318+
assert isinstance(provider_a, FastEmbedEmbeddingProvider)
319+
320+
monkeypatch.setenv("FASTEMBED_CACHE_PATH", str(tmp_path / "alt-cache"))
321+
provider_b = create_embedding_provider(BasicMemoryConfig(**base_kwargs))
322+
323+
assert isinstance(provider_b, FastEmbedEmbeddingProvider)
324+
assert provider_b is not provider_a
325+
assert provider_a.cache_dir == str(config_home / ".basic-memory" / "fastembed_cache")
326+
assert provider_b.cache_dir == str(tmp_path / "alt-cache")
327+
328+
268329
def test_fastembed_provider_reports_runtime_log_attrs():
269330
"""FastEmbed should expose the resolved runtime knobs for batch startup logs."""
270331
provider = FastEmbedEmbeddingProvider(batch_size=128, threads=4, parallel=2)

tests/test_config.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
ConfigManager,
1111
ProjectEntry,
1212
ProjectMode,
13+
default_fastembed_cache_dir,
14+
resolve_data_dir,
1315
)
1416
from pathlib import Path
1517

@@ -129,6 +131,54 @@ def test_app_database_path_defaults_to_home_data_dir(self, config_home, monkeypa
129131
assert config.data_dir_path == config_home / ".basic-memory"
130132
assert config.app_database_path == config_home / ".basic-memory" / "memory.db"
131133

134+
def test_semantic_embedding_cache_dir_field_stays_none_by_default(
135+
self, config_home, monkeypatch
136+
):
137+
"""The raw config field stays None so it isn't persisted into config.json.
138+
139+
Resolution to a concrete path happens in embedding_provider_factory at
140+
provider construction time, so ``BASIC_MEMORY_CONFIG_DIR`` and
141+
``FASTEMBED_CACHE_PATH`` changes take effect on every run instead of
142+
being frozen by the first save. See #741.
143+
"""
144+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
145+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
146+
147+
config = BasicMemoryConfig()
148+
149+
assert config.semantic_embedding_cache_dir is None
150+
151+
def test_semantic_embedding_cache_dir_not_persisted_in_model_dump(
152+
self, config_home, monkeypatch
153+
):
154+
"""model_dump must not bake a resolved cache path into config.json.
155+
156+
Regression guard for #741: persisting the default would freeze stale
157+
paths when users later change BASIC_MEMORY_CONFIG_DIR or
158+
FASTEMBED_CACHE_PATH.
159+
"""
160+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
161+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
162+
163+
dumped = BasicMemoryConfig().model_dump(mode="json")
164+
165+
assert dumped["semantic_embedding_cache_dir"] is None
166+
167+
def test_semantic_embedding_cache_dir_explicit_user_value_preserved(
168+
self, config_home, monkeypatch
169+
):
170+
"""An explicit user override still round-trips through model_dump."""
171+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
172+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
173+
174+
config = BasicMemoryConfig(semantic_embedding_cache_dir="/custom/explicit/path")
175+
176+
assert config.semantic_embedding_cache_dir == "/custom/explicit/path"
177+
assert (
178+
config.model_dump(mode="json")["semantic_embedding_cache_dir"]
179+
== "/custom/explicit/path"
180+
)
181+
132182
def test_explicit_default_project_preserved(self, config_home, monkeypatch):
133183
"""Test that a valid explicit default_project is not overwritten by model_post_init."""
134184
monkeypatch.delenv("BASIC_MEMORY_HOME", raising=False)
@@ -252,6 +302,65 @@ def test_config_file_without_default_project_key(self, config_home, monkeypatch)
252302
assert loaded.default_project == "work"
253303

254304

305+
class TestDataDirHelpers:
306+
"""Module-level helpers that resolve the Basic Memory data directory."""
307+
308+
def test_resolve_data_dir_defaults_to_home_dot_basic_memory(self, config_home, monkeypatch):
309+
"""Without BASIC_MEMORY_CONFIG_DIR, resolver returns ~/.basic-memory."""
310+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
311+
312+
assert resolve_data_dir() == config_home / ".basic-memory"
313+
314+
def test_resolve_data_dir_honors_config_dir_env(self, tmp_path, monkeypatch):
315+
"""BASIC_MEMORY_CONFIG_DIR overrides the default location."""
316+
custom = tmp_path / "elsewhere"
317+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom))
318+
319+
assert resolve_data_dir() == custom
320+
321+
def test_default_fastembed_cache_dir_uses_data_dir(self, config_home, monkeypatch):
322+
"""Default cache path is a subdir of the Basic Memory data dir."""
323+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
324+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
325+
326+
assert default_fastembed_cache_dir() == str(
327+
config_home / ".basic-memory" / "fastembed_cache"
328+
)
329+
330+
def test_default_fastembed_cache_dir_env_override(self, tmp_path, monkeypatch):
331+
"""FASTEMBED_CACHE_PATH is preferred when set."""
332+
custom = tmp_path / "custom-cache"
333+
monkeypatch.setenv("FASTEMBED_CACHE_PATH", str(custom))
334+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path / "state"))
335+
336+
assert default_fastembed_cache_dir() == str(custom)
337+
338+
def test_default_fastembed_cache_dir_never_falls_back_to_fastembed_tmp_default(
339+
self, config_home, monkeypatch
340+
):
341+
"""Regression guard for #741.
342+
343+
FastEmbed's own fallback when ``cache_dir`` is ``None`` is
344+
``<system tmp>/fastembed_cache`` — the exact path that disappears in
345+
sandboxed MCP runtimes (Codex CLI). Ensure Basic Memory's resolver
346+
never lands on that path.
347+
348+
Compared as exact paths rather than ``startswith(tempfile.gettempdir())``
349+
because the test runner itself can legitimately live under ``/tmp``
350+
(pytest's ``tmp_path`` does on Linux CI), and that's not the bug we
351+
care about here.
352+
"""
353+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
354+
monkeypatch.delenv("FASTEMBED_CACHE_PATH", raising=False)
355+
356+
resolved = Path(default_fastembed_cache_dir())
357+
358+
# Must equal the data-dir-relative default.
359+
assert resolved == config_home / ".basic-memory" / "fastembed_cache"
360+
# And must not equal FastEmbed's own <tempdir>/fastembed_cache fallback.
361+
assert resolved != Path(tempfile.gettempdir()) / "fastembed_cache"
362+
363+
255364
class TestConfigManager:
256365
"""Test ConfigManager functionality."""
257366

0 commit comments

Comments
 (0)