diff --git a/README.md b/README.md index 4c5d8ee..9d4af90 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,8 @@ For `/reindex-history` the `phase` value is `discovery|embedding|upserting` and | `QDRANT_COMMITS_COLLECTION` | `git_commits` | Collection name for commit message vectors | | `EMBEDDINGS_PROVIDER` | `jina` | One of `jina`, `jina-api`, `voyage`, `openai`, `ollama` — see *Embedding providers* below | | `GIT_HISTORY_MAX_COMMITS` | `500` | Max commits indexed per service | +| `CODE_CONTEXT_CACHE_SIZE` | `128` | Files cached in memory for `get_code_context` (keyed on blob SHA); `0` disables | +| `CODE_CONTEXT_CACHE_TTL` | `900` | Seconds a cached file content stays valid | | `MCP_TRANSPORT` | `streamable-http` | One of `streamable-http`, `sse`, `stdio` | | `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8090` | Server bind address | | `CONFIG_PATH` | `./config.yaml` | Path to the services config file | diff --git a/docs/configuration.md b/docs/configuration.md index 88fdc36..a73617d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,6 +101,8 @@ Used when `EMBEDDINGS_PROVIDER=ollama`. Requires a running [Ollama](https://olla | `GITHUB_TOKEN` | `""` | GitHub personal access token (or GitHub App installation token). Required for all indexing operations. Without it, GitHub API calls return 403. A single token is used for every service, whether from `config.yaml` or the dynamic registry — see the note in [Dynamic Service Registration](#dynamic-service-registration). | | `CONFIG_PATH` | `./config.yaml` | Path to the optional services config file. Relative to the working directory at server start. A missing file means zero `config.yaml`-defined services, not an error. | | `GIT_HISTORY_MAX_COMMITS` | `500` | Maximum number of commits fetched per service for git history indexing. | +| `CODE_CONTEXT_CACHE_SIZE` | `128` | Number of file contents `get_code_context` keeps cached in memory, keyed on the indexed git blob SHA. Repeated context fetches for the same file are served locally instead of hitting the GitHub API. Set to `0` to disable caching. | +| `CODE_CONTEXT_CACHE_TTL` | `900` | Seconds a cached file content stays valid. Entries are keyed on content (blob SHA), so the TTL only bounds memory for files that fall out of use. | | `EMBEDDING_MAX_CHARS` | provider-aware — see below | Max characters of a symbol's dense-embedding text (preamble + signature + docstring + source). Oversized symbols are truncated (with a logged `WARNING`). Set this explicitly to override the derived default for any provider. | Since providers differ hugely in context window (2K–32K tokens), `EMBEDDING_MAX_CHARS` defaults to a value derived from `EMBEDDINGS_PROVIDER`'s default model (~3 chars/token, ~10% safety margin for the preamble). This is a per-provider default, not per-model — if you change `*_MODEL` to a model with a smaller or larger window than the provider's default, set `EMBEDDING_MAX_CHARS` explicitly. diff --git a/pyproject.toml b/pyproject.toml index 6ee9ab6..68e5cad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "pyyaml>=6.0", "httpx>=0.28.0", "fastembed>=0.4.0", + "cachetools>=7.1.6", ] [dependency-groups] diff --git a/server/config.py b/server/config.py index 46eb24d..491da25 100644 --- a/server/config.py +++ b/server/config.py @@ -102,6 +102,11 @@ def _apply_default_embedding_max_chars(self) -> Settings: mcp_host: str = Field(default="127.0.0.1", alias="MCP_HOST") mcp_port: int = Field(default=8090, alias="MCP_PORT") + # get_code_context fetches file contents from GitHub. Contents are cached by git + # blob SHA so repeated calls for the same file in a session are served locally. + code_context_cache_size: int = Field(default=128, alias="CODE_CONTEXT_CACHE_SIZE") + code_context_cache_ttl: float = Field(default=900.0, alias="CODE_CONTEXT_CACHE_TTL") + config_path: str = Field(default="./config.yaml", alias="CONFIG_PATH") github_token: str = Field(default="", alias="GITHUB_TOKEN") diff --git a/server/tools/file_cache.py b/server/tools/file_cache.py new file mode 100644 index 0000000..35fd13e --- /dev/null +++ b/server/tools/file_cache.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from cachetools import TTLCache + + +class BlobContentCache: + """LRU + TTL cache of file contents keyed on ``(repo, blob_sha)``. + + A git blob SHA is a content fingerprint, so an entry can only go stale in the + sense that the *index* may have moved on to a newer blob — in which case the + key changes and the old entry is simply never read again. The TTL exists only + to bound memory for keys that fall out of use. + + Eviction and expiry are ``cachetools.TTLCache``; this wrapper only adds typed + accessors and treats ``max_entries <= 0`` as "caching disabled" (TTLCache + itself raises on insert when ``maxsize`` is 0). Not thread-safe — callers are + expected to share one event loop, and nothing awaits between get and put. + """ + + def __init__(self, max_entries: int, ttl_seconds: float) -> None: + self._cache: TTLCache[tuple[str, str], str] | None = ( + TTLCache(maxsize=max_entries, ttl=ttl_seconds) if max_entries > 0 else None + ) + + def get(self, repo: str, blob_sha: str) -> str | None: + if self._cache is None: + return None + return self._cache.get((repo, blob_sha)) + + def put(self, repo: str, blob_sha: str, content: str) -> None: + if self._cache is None: + return + self._cache[(repo, blob_sha)] = content + + def clear(self) -> None: + if self._cache is not None: + self._cache.clear() diff --git a/server/tools/search.py b/server/tools/search.py index e902a35..1cb93fb 100644 --- a/server/tools/search.py +++ b/server/tools/search.py @@ -10,9 +10,16 @@ from server.indexer.github_source import fetch_file_content from server.state import get_service_registry, get_sparse_provider, get_store from server.store.service_registry import load_effective_services +from server.tools.file_cache import BlobContentCache logger = logging.getLogger(__name__) +# Shared across all get_code_context calls for the process lifetime. +file_content_cache = BlobContentCache( + max_entries=settings.code_context_cache_size, + ttl_seconds=settings.code_context_cache_ttl, +) + # find_usages filters the symbol's own definition(s) out of the fused results. # Over-fetch by this many hits so those removals don't eat into the caller's # requested limit. @@ -212,18 +219,30 @@ async def get_code_context( # path matches the actual location in the GitHub repo tree. rel_path = file_path[len(svc.name) + 1 :] path_in_repo = f"{svc.root.rstrip('/')}/{rel_path}" if svc.root else rel_path - try: - raw = await fetch_file_content( - settings.github_token, svc.github_repo, path_in_repo, svc.github_ref - ) + + # The indexed file_hash IS the git blob SHA, so it doubles as a content + # fingerprint for the cache. Entries indexed before file_hash existed skip + # the cache rather than risk keying on a path whose content can change. + blob_sha = file_info.get("file_hash") + content = ( + file_content_cache.get(svc.github_repo, blob_sha) if blob_sha else None + ) + if content is None: + try: + raw = await fetch_file_content( + settings.github_token, svc.github_repo, path_in_repo, svc.github_ref + ) + except httpx.HTTPError as exc: + logger.exception( + "Failed to fetch %s from GitHub (%s)", file_path, svc.github_repo + ) + return ( + f"Failed to fetch `{file_path}` from GitHub " + f"({svc.github_repo}): {exc}" + ) content = raw.decode("utf-8", errors="replace") - except httpx.HTTPError as exc: - logger.exception( - "Failed to fetch %s from GitHub (%s)", file_path, svc.github_repo - ) - return ( - f"Failed to fetch `{file_path}` from GitHub ({svc.github_repo}): {exc}" - ) + if blob_sha: + file_content_cache.put(svc.github_repo, blob_sha, content) if symbol_name is None: return f"```\n{content}\n```" diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index 459c05e..4dedd26 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -4,8 +4,19 @@ from dataclasses import dataclass, field from typing import Any +import pytest from mcp.server.fastmcp import FastMCP +from server.tools.search import file_content_cache + + +@pytest.fixture(autouse=True) +def clear_file_content_cache(): + """The get_code_context content cache is process-wide — keep tests isolated.""" + file_content_cache.clear() + yield + file_content_cache.clear() + def get_tool(register: Callable[[FastMCP], None], name: str) -> Callable: """Register a tool module against a throwaway FastMCP instance and return diff --git a/tests/tools/test_file_cache.py b/tests/tools/test_file_cache.py new file mode 100644 index 0000000..b6131d7 --- /dev/null +++ b/tests/tools/test_file_cache.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from server.tools.file_cache import BlobContentCache + + +def test_put_then_get_returns_content() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=60) + cache.put("org/orders", "blob1", "class Order {}") + + assert cache.get("org/orders", "blob1") == "class Order {}" + + +def test_get_missing_key_returns_none() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=60) + + assert cache.get("org/orders", "blob1") is None + + +def test_same_blob_sha_in_different_repos_is_a_separate_entry() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=60) + cache.put("org/orders", "blob1", "orders") + + assert cache.get("org/billing", "blob1") is None + + +def test_expired_entry_is_evicted_on_read() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=0) + cache.put("org/orders", "blob1", "class Order {}") + + assert cache.get("org/orders", "blob1") is None + + +def test_least_recently_used_entry_is_evicted_when_full() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=60) + cache.put("org/orders", "blob1", "one") + cache.put("org/orders", "blob2", "two") + cache.get("org/orders", "blob1") # blob2 is now least recently used + cache.put("org/orders", "blob3", "three") + + assert cache.get("org/orders", "blob2") is None + assert cache.get("org/orders", "blob1") == "one" + assert cache.get("org/orders", "blob3") == "three" + + +def test_zero_max_entries_disables_caching() -> None: + cache = BlobContentCache(max_entries=0, ttl_seconds=60) + cache.put("org/orders", "blob1", "one") + + assert cache.get("org/orders", "blob1") is None + + +def test_clear_drops_all_entries() -> None: + cache = BlobContentCache(max_entries=2, ttl_seconds=60) + cache.put("org/orders", "blob1", "one") + cache.clear() + + assert cache.get("org/orders", "blob1") is None diff --git a/tests/tools/test_search.py b/tests/tools/test_search.py index 08c5575..4df4a2b 100644 --- a/tests/tools/test_search.py +++ b/tests/tools/test_search.py @@ -462,6 +462,124 @@ async def test_get_code_context_falls_back_to_text_search_when_symbol_not_in_ind assert "Found `placeOrder` near line 2" in result +async def test_get_code_context_serves_repeat_calls_from_cache() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"} + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.get_service_registry", return_value=AsyncMock()), + patch( + "server.tools.search.load_effective_services", + new_callable=AsyncMock, + return_value=[svc], + ), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.github_token = "token" + mock_fetch.return_value = b"class Order {}" + + first = await get_code_context("orders/Order.java") + second = await get_code_context("orders/Order.java") + + assert first == second == "```\nclass Order {}\n```" + mock_fetch.assert_awaited_once() + + +async def test_get_code_context_refetches_when_blob_sha_changed() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.get_service_registry", return_value=AsyncMock()), + patch( + "server.tools.search.load_effective_services", + new_callable=AsyncMock, + return_value=[svc], + ), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.github_token = "token" + + store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"} + mock_fetch.return_value = b"class Order {}" + await get_code_context("orders/Order.java") + + store.get_file_info.return_value = {"service": "orders", "file_hash": "blob2"} + mock_fetch.return_value = b"class Order { int id; }" + result = await get_code_context("orders/Order.java") + + assert result == "```\nclass Order { int id; }\n```" + assert mock_fetch.await_count == 2 + + +async def test_get_code_context_skips_cache_when_file_hash_missing() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders"} + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.get_service_registry", return_value=AsyncMock()), + patch( + "server.tools.search.load_effective_services", + new_callable=AsyncMock, + return_value=[svc], + ), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.github_token = "token" + mock_fetch.return_value = b"class Order {}" + + await get_code_context("orders/Order.java") + await get_code_context("orders/Order.java") + + assert mock_fetch.await_count == 2 + + +async def test_get_code_context_failed_fetch_is_not_cached() -> None: + get_code_context = _tool("get_code_context") + store = AsyncMock() + store.get_file_info.return_value = {"service": "orders", "file_hash": "blob1"} + svc = ServiceConfig(name="orders", github_repo="org/orders", exclude=[]) + + with ( + patch("server.tools.search.get_store", return_value=store), + patch("server.tools.search.get_service_registry", return_value=AsyncMock()), + patch( + "server.tools.search.load_effective_services", + new_callable=AsyncMock, + return_value=[svc], + ), + patch("server.tools.search.settings") as mock_settings, + patch( + "server.tools.search.fetch_file_content", new_callable=AsyncMock + ) as mock_fetch, + ): + mock_settings.github_token = "token" + mock_fetch.side_effect = [httpx.HTTPError("boom"), b"class Order {}"] + + failed = await get_code_context("orders/Order.java") + recovered = await get_code_context("orders/Order.java") + + assert "Failed to fetch" in failed + assert recovered == "```\nclass Order {}\n```" + + async def test_get_code_context_symbol_not_found_anywhere() -> None: get_code_context = _tool("get_code_context") store = AsyncMock() diff --git a/uv.lock b/uv.lock index a30bab4..c5e45cc 100644 --- a/uv.lock +++ b/uv.lock @@ -47,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "cachetools" +version = "7.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -1306,6 +1315,7 @@ name = "semcode" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "cachetools" }, { name = "fastembed" }, { name = "httpx" }, { name = "mcp" }, @@ -1349,6 +1359,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=7.1.6" }, { name = "fastembed", specifier = ">=0.4.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "mcp", specifier = ">=1.28.1" },