diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..96b6aea --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Package validation +on: + pull_request: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: +permissions: + contents: read +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ['3.12'] + include: + - os: ubuntu-22.04 + python: '3.8' + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install --upgrade build 'setuptools>=61.0' wheel + - run: python -m pip install -e '.[dev]' + - run: python -m pytest -q + - run: python scripts/verify_package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ad4ac..868bcac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the CueMap Python SDK will be documented in this file. +## [0.7.3] - 2026-08-27 + +### Changed +- Synchronized the SDK patch release and documentation with CueMap Engine v0.7.3. +- Documented compatibility with the engine's Tree-sitter-backed Swift, Dart, Objective-C, and Kotlin ingestion support. +- Changed the default direct-client and embedded-engine port from `8080` to `8735`. + +### Added +- Added synchronous and asynchronous project lifecycle methods plus portable project `pack`, `load`, `push`, and `pull`; project listings expose the engine's `loaded` state. +- Added synchronous and asynchronous `sync_project()` for fast-forward S3 project history. + ## [0.7.2] - 2026-08-04 ### Added diff --git a/README.md b/README.md index 1ea1f90..a0f742f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,23 @@ -# CueMap Python SDK +

+ CueMap +

+ +

CueMap Python SDK

+ +

A polished Python client for fast, accurate, and explainable agent memory.

+ +

+ PyPI + Python versions + License + Engine compatibility +

**High-performance temporal-associative memory store** designed for dynamic contextual retrieval. ## Overview -CueMap implements a **Continuous Gradient Algorithm** optimized for associative data structures: +CueMap uses **temporal-associative retrieval**: lexical and structural candidate generation, with optional semantic reranking. Its main components are: 1. **Intersection (Context Filter)**: Triangulates relevant memories by overlapping cues 2. **Local semantic reranking**: Uses bundled qint8 MiniLM-L3 by default, or q4 MiniLM-L3 with the edge profile, for bounded semantic ranking inside the engine. @@ -12,9 +25,9 @@ CueMap implements a **Continuous Gradient Algorithm** optimized for associative 4. **Reinforcement (Access-based Learning)**: Frequently accessed memories gain signal strength, remaining highly accessible even as they age. 5. **Deterministic Facets & Intent Routing**: Extracts synchronous source, evidence, temporal, type, and entity facets, then uses sparse intent cues and reranking during recall. -As of v0.7.2, CueMap keeps deterministic lexical candidate discovery and adds bundled qint8 `all-MiniLM-L3-v2` for bounded hybrid semantic and intent reranking. The `edge` engine profile uses a q4 build of the same model. No runtime model download is required, and callers can disable the encoder or provide their own vectors. +As of v0.7.3, CueMap keeps deterministic lexical candidate discovery and adds bundled qint8 `paraphrase-MiniLM-L3-v2` for bounded hybrid semantic and intent reranking. The `edge` engine profile uses a q4 build of the same model. No runtime model download is required, and callers can disable the encoder or provide their own vectors. -v0.7.2 also preserves numeric per-project memory IDs everywhere. If callers need deterministic upsert/dedupe identity, pass `source_key`; memory IDs remain compact runtime addresses. +v0.7.3 also preserves numeric per-project memory IDs everywhere. If callers need deterministic upsert/dedupe identity, pass `source_key`; memory IDs remain compact runtime addresses. Use this SDK to talk to the Rust engine from Python applications. @@ -29,7 +42,7 @@ pip install cuemap ### 1. Start the Engine ```bash -docker run -p 8080:8080 cuemap/engine:latest +docker run -p 8735:8735 cuemap/engine:latest ``` ### 2. Basic Usage @@ -99,9 +112,29 @@ print(response["proof"]) # Cryptographic proof of context retrieval ``` -### v0.7.2 Recall Controls +### Project memory lifecycle -CueMap v0.7.2 adds local semantic query signals alongside temporal query intent and optional reconstruction passes for longer conversational/codebase context. +The engine can unload inactive project contexts while keeping their snapshots +on disk. Normal project operations demand-load a project when needed, so the +first request after an unload may take longer. Use the explicit helpers when +you want to control residency: + +```python +client.unload_project("older-repository") +client.load_project("older-repository") +client.save_project("older-repository") # persist without unloading + +for project in client.list_projects(): + print(project["project_id"], project["loaded"]) +``` + +Portable projects use the same four operations as the CLI: +`pack_project()`, `load_project_package()`, `push_project()`, and `pull_project()`. +Use `sync_project(project_id, "s3://bucket/team")` for conflict-safe fast-forward sync. + +### v0.7.3 Recall Controls + +CueMap v0.7.3 adds local semantic query signals alongside temporal query intent and optional reconstruction passes for longer conversational/codebase context. ```python results = client.recall( @@ -215,3 +248,15 @@ async with AsyncCueMap() as client: ## License MIT + +### Recall previews + +The engine's `POST /recall` accepts `response_mode: "preview"` and optional +`preview_chars` (100–2000 UTF-16 code units, default 200). Full content remains +the default. Previews replace each hit's `content` with a leading `preview`, +`content_truncated`, and `content_length`, preserving metadata and ranking. +Use previews for broad discovery, then fetch a selected memory with +`GET /memories/{id}?decoded=true` or read its source. Metadata and diagnostics +are not capped. TypeScript request objects and Python sync/async `recall` +accept these same options; Python returns `RecallPreviewResult` for ungrouped +preview results. The updated engine is required. diff --git a/cuemap/__init__.py b/cuemap/__init__.py index 1fb2add..3aa8bb2 100644 --- a/cuemap/__init__.py +++ b/cuemap/__init__.py @@ -19,11 +19,11 @@ from .client import CueMap, AsyncCueMap from .embedded import EmbeddedCueMap, resolve_cuemap_binary -from .models import Memory, RecallResult +from .models import Memory, RecallResult, RecallPreviewResult from .exceptions import CueMapError, ConnectionError, AuthenticationError from .grounding import CueMapGroundingRetriever, AsyncCueMapGroundingRetriever -__version__ = "0.7.2" +__version__ = "0.7.3" __all__ = [ "CueMap", "AsyncCueMap", @@ -31,6 +31,7 @@ "resolve_cuemap_binary", "Memory", "RecallResult", + "RecallPreviewResult", "CueMapError", "ConnectionError", "AuthenticationError", diff --git a/cuemap/client.py b/cuemap/client.py index ed79beb..3d51792 100644 --- a/cuemap/client.py +++ b/cuemap/client.py @@ -3,7 +3,7 @@ import httpx from typing import List, Optional, Dict, Any -from .models import Memory, RecallResult +from .models import Memory, RecallResult, RecallPreviewResult from .exceptions import CueMapError, ConnectionError, AuthenticationError @@ -27,7 +27,7 @@ class CueMap: def __init__( self, - url: str = "http://localhost:8080", + url: str = "http://localhost:8735", api_key: Optional[str] = None, project_id: Optional[str] = None, timeout: float = 30.0 @@ -185,6 +185,8 @@ def recall( disable_systems_consolidation: Optional[bool] = None, semantic_mode: str = "hybrid", query_embedding: Optional[List[float]] = None, + response_mode: str = "full", + preview_chars: int = 200, ) -> List[RecallResult]: """ Recall memories by cues or natural language. @@ -231,6 +233,8 @@ def recall( "cuebridge_gap_limit": cuebridge_gap_limit, "semantic_mode": semantic_mode, "query_embedding": query_embedding, + "response_mode": response_mode, + "preview_chars": preview_chars, } if cues: payload["cues"] = cues @@ -257,7 +261,7 @@ def recall( if projects and isinstance(results, list) and len(results) > 0 and "project_id" in results[0]: return data - return [RecallResult(**r) for r in results] + return [(RecallPreviewResult if response_mode == "preview" else RecallResult)(**r) for r in results] def recall_grounded( self, @@ -302,8 +306,12 @@ def recall_grounded( return response.json() - def list_projects(self) -> List[str]: - """List all projects (multi-tenant only).""" + def list_projects(self) -> List[Dict[str, Any]]: + """List all projects and their runtime state (multi-tenant only). + + Each item includes ``loaded`` so callers can distinguish a project + whose snapshot exists on disk from one currently resident in RAM. + """ response = self.client.get( "/projects", headers=self._headers() @@ -312,6 +320,93 @@ def list_projects(self) -> List[str]: raise CueMapError(f"Failed to list projects: {response.text}") return response.json() + def load_project(self, project_id: str) -> Dict[str, Any]: + """Load a project's snapshot into engine memory.""" + response = self.client.post( + f"/projects/{project_id}/load", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to load project: {response.text}") + return response.json() + + def save_project(self, project_id: str) -> Dict[str, Any]: + """Persist a current project snapshot without unloading it.""" + response = self.client.post( + f"/projects/{project_id}/save", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to save project: {response.text}") + return response.json() + + def unload_project(self, project_id: str) -> Dict[str, Any]: + """Persist and unload a project from engine memory.""" + response = self.client.post( + f"/projects/{project_id}/unload", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to unload project: {response.text}") + return response.json() + + def pack_project(self, project_id: str) -> bytes: + """Return a ready-to-query project as portable ``.cuemap`` bytes.""" + response = self.client.post( + f"/projects/{project_id}/pack", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to pack project: {response.text}") + return response.content + + def load_project_package(self, package: bytes) -> Dict[str, Any]: + """Install and warm a portable ``.cuemap`` package.""" + response = self.client.post( + "/projects/load", + content=package, + headers={ + **self._headers(), + "Content-Type": "application/vnd.cuemap.project", + }, + ) + if response.status_code != 200: + raise CueMapError(f"Failed to load project package: {response.text}") + return response.json() + + def push_project(self, project_id: str, destination: str) -> Dict[str, Any]: + """Pack and upload a project using the server's configured AWS CLI.""" + response = self.client.post( + f"/projects/{project_id}/push", + json={"destination": destination}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to push project: {response.text}") + return response.json() + + def pull_project(self, source: str) -> Dict[str, Any]: + """Download, install, and warm a project using the server's AWS CLI.""" + response = self.client.post( + "/projects/pull", + json={"source": source}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to pull project: {response.text}") + return response.json() + + def sync_project(self, project_id: str, remote: str) -> Dict[str, Any]: + """Fast-forward a project through its immutable S3 sync history.""" + response = self.client.post( + f"/projects/{project_id}/sync", + json={"remote": remote}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to sync project: {response.text}") + return response.json() + def create_project(self, project_id: str) -> Dict[str, Any]: """Create a project.""" response = self.client.post( @@ -758,7 +853,7 @@ class AsyncCueMap: def __init__( self, - url: str = "http://localhost:8080", + url: str = "http://localhost:8735", api_key: Optional[str] = None, project_id: Optional[str] = None, timeout: float = 30.0 @@ -889,6 +984,8 @@ async def recall( disable_systems_consolidation: Optional[bool] = None, semantic_mode: str = "hybrid", query_embedding: Optional[List[float]] = None, + response_mode: str = "full", + preview_chars: int = 200, ) -> List[RecallResult]: """Recall memories (async).""" payload = { @@ -915,6 +1012,8 @@ async def recall( "cuebridge_gap_limit": cuebridge_gap_limit, "semantic_mode": semantic_mode, "query_embedding": query_embedding, + "response_mode": response_mode, + "preview_chars": preview_chars, } if cues: payload["cues"] = cues @@ -941,7 +1040,7 @@ async def recall( if projects and isinstance(results, list) and len(results) > 0 and "project_id" in results[0]: return data - return [RecallResult(**r) for r in results] + return [(RecallPreviewResult if response_mode == "preview" else RecallResult)(**r) for r in results] async def recall_grounded( self, @@ -979,8 +1078,8 @@ async def recall_grounded( return response.json() - async def list_projects(self) -> List[str]: - """List all projects (async, multi-tenant only).""" + async def list_projects(self) -> List[Dict[str, Any]]: + """List all projects and their runtime state (async, multi-tenant only).""" response = await self.client.get( "/projects", headers=self._headers() @@ -989,6 +1088,93 @@ async def list_projects(self) -> List[str]: raise CueMapError(f"Failed to list projects: {response.text}") return response.json() + async def load_project(self, project_id: str) -> Dict[str, Any]: + """Load a project's snapshot into engine memory (async).""" + response = await self.client.post( + f"/projects/{project_id}/load", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to load project: {response.text}") + return response.json() + + async def save_project(self, project_id: str) -> Dict[str, Any]: + """Persist a current project snapshot without unloading it (async).""" + response = await self.client.post( + f"/projects/{project_id}/save", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to save project: {response.text}") + return response.json() + + async def unload_project(self, project_id: str) -> Dict[str, Any]: + """Persist and unload a project from engine memory (async).""" + response = await self.client.post( + f"/projects/{project_id}/unload", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to unload project: {response.text}") + return response.json() + + async def pack_project(self, project_id: str) -> bytes: + """Return a ready-to-query project as portable ``.cuemap`` bytes (async).""" + response = await self.client.post( + f"/projects/{project_id}/pack", + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to pack project: {response.text}") + return response.content + + async def load_project_package(self, package: bytes) -> Dict[str, Any]: + """Install and warm a portable ``.cuemap`` package (async).""" + response = await self.client.post( + "/projects/load", + content=package, + headers={ + **self._headers(), + "Content-Type": "application/vnd.cuemap.project", + }, + ) + if response.status_code != 200: + raise CueMapError(f"Failed to load project package: {response.text}") + return response.json() + + async def push_project(self, project_id: str, destination: str) -> Dict[str, Any]: + """Pack and upload a project using the server's configured AWS CLI (async).""" + response = await self.client.post( + f"/projects/{project_id}/push", + json={"destination": destination}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to push project: {response.text}") + return response.json() + + async def pull_project(self, source: str) -> Dict[str, Any]: + """Download, install, and warm a project using the server's AWS CLI (async).""" + response = await self.client.post( + "/projects/pull", + json={"source": source}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to pull project: {response.text}") + return response.json() + + async def sync_project(self, project_id: str, remote: str) -> Dict[str, Any]: + """Fast-forward a project through its immutable S3 sync history (async).""" + response = await self.client.post( + f"/projects/{project_id}/sync", + json={"remote": remote}, + headers=self._headers(), + ) + if response.status_code != 200: + raise CueMapError(f"Failed to sync project: {response.text}") + return response.json() + async def create_project(self, project_id: str) -> Dict[str, Any]: """Create a project (async).""" response = await self.client.post( diff --git a/cuemap/embedded.py b/cuemap/embedded.py index 3fbd1d6..3dbdca3 100644 --- a/cuemap/embedded.py +++ b/cuemap/embedded.py @@ -39,9 +39,11 @@ def _free_port() -> int: def _platform_package() -> str: - operating_system = platform.system().lower() + operating_system = {"Windows": "win32", "Darwin": "darwin", "Linux": "linux"}.get(platform.system()) machine = platform.machine().lower() - architecture = "arm64" if machine in {"arm64", "aarch64"} else "x64" + architecture = {"arm64": "arm64", "aarch64": "arm64", "amd64": "x64", "x86_64": "x64"}.get(machine) + if operating_system is None or architecture is None or (operating_system == "win32" and architecture != "x64"): + raise RuntimeError(f"Unsupported CueMap platform: {platform.system()} {machine}") return f"@cuemap-dev/engine-{operating_system}-{architecture}" @@ -49,9 +51,16 @@ def _npm_global_binary() -> Optional[str]: npm = shutil.which("npm") if not npm: return None + npm_command = [npm] + if platform.system() == "Windows": + node = shutil.which("node") + npm_cli = Path(npm).parent / "node_modules" / "npm" / "bin" / "npm-cli.js" + if not node or not npm_cli.is_file(): + return None + npm_command = [node, str(npm_cli)] try: root = subprocess.run( - [npm, "root", "--global"], + [*npm_command, "root", "--global"], check=True, capture_output=True, text=True, @@ -59,8 +68,8 @@ def _npm_global_binary() -> Optional[str]: ).stdout.strip() except (OSError, subprocess.SubprocessError): return None - binary_name = "cuemap.exe" if os.name == "nt" else "cuemap" - candidate = Path(root) / _platform_package() / "bin" / binary_name + package_bin = Path(root) / _platform_package() / "bin" + candidate = package_bin / ("cuemap-native.exe" if platform.system() == "Windows" else "cuemap") return str(candidate) if candidate.is_file() else None @@ -75,7 +84,7 @@ def resolve_cuemap_binary(explicit_path: Optional[str] = None) -> str: return str(path) installed = shutil.which("cuemap") - if installed: + if installed and Path(installed).suffix.lower() not in {".cmd", ".bat", ".ps1"}: return installed global_binary = _npm_global_binary() @@ -113,7 +122,7 @@ def start( *, url: Optional[str] = None, bin_path: Optional[str] = None, - port: int = 8080, + port: int = 8735, config_path: Optional[str] = None, api_key: Optional[str] = None, startup_timeout: float = 15.0, @@ -138,13 +147,27 @@ def start( selected_port = _free_port() if status == "occupied" else port selected_url = f"http://127.0.0.1:{selected_port}" executable = resolve_cuemap_binary(bin_path) - arguments = [executable, "start", "--port", str(selected_port)] + command = [executable] + if platform.system() == "Windows" and Path(executable).suffix.lower() != ".exe": + if Path(executable).suffix.lower() in {".cmd", ".bat", ".ps1"}: + raise ValueError("Use the native .exe or the npm package bin/cuemap wrapper, not a shell shim") + node = shutil.which("node") + if not node: + raise FileNotFoundError("Node.js is required to launch the CueMap npm wrapper") + command = [node, executable] + arguments = [*command, "start", "--port", str(selected_port)] if config_path: arguments.extend(["--config", str(Path(config_path).expanduser())]) process_env = dict(os.environ) if env: process_env.update(env) process_env["CUEMAP_PORT"] = str(selected_port) + process_env["CUEMAP_HOST"] = "127.0.0.1" + tokenizer = Path(executable).parent.parent / "assets" / "en_tokenizer.bin" + if Path(executable).name == "cuemap-native.exe" and tokenizer.is_file(): + process_env.setdefault("TOKENIZER_PATH", str(tokenizer)) + if api_key: + process_env["CUEMAP_API_KEY"] = api_key log(f"Starting CueMap at {selected_url}") process = subprocess.Popen( @@ -164,7 +187,7 @@ def start( return cls(selected_url, True, process, shutdown_timeout) time.sleep(0.1) - process.terminate() + cls(selected_url, True, process, shutdown_timeout).stop() raise TimeoutError(f"CueMap did not become ready within {startup_timeout:g}s") def stop(self) -> None: diff --git a/cuemap/models.py b/cuemap/models.py index 27c50a2..e3a8fc5 100644 --- a/cuemap/models.py +++ b/cuemap/models.py @@ -37,3 +37,11 @@ class RecallResult(BaseModel): structural_cues: List[str] = Field(default_factory=list) metadata: Dict[str, Any] = Field(default_factory=dict) explain: Optional[Dict[str, Any]] = None + + +class RecallPreviewResult(RecallResult): + """Engine-provided leading excerpt; fetch the memory for full evidence.""" + content: Optional[str] = None + preview: str + content_truncated: bool + content_length: int diff --git a/pyproject.toml b/pyproject.toml index 0fac6d0..d0384fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "cuemap" -version = "0.7.2" +version = "0.7.3" description = "CueMap Python SDK - High-performance temporal-associative memory" readme = "README.md" requires-python = ">=3.8" diff --git a/scripts/verify_package.py b/scripts/verify_package.py index 7082f03..c2f1967 100644 --- a/scripts/verify_package.py +++ b/scripts/verify_package.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import shutil import sys import tempfile import venv @@ -19,7 +20,20 @@ def run(*args: str, cwd: Path = ROOT) -> None: with tempfile.TemporaryDirectory(prefix="cuemap-python-pack-") as temporary: output = Path(temporary) / "dist" output.mkdir() - run(sys.executable, "-m", "build", "--no-isolation", "--sdist", "--wheel", "--outdir", str(output)) + # Run from the temporary directory so this repository's ignored `build/` + # artifact directory cannot shadow the `build` packaging module. + run( + sys.executable, + "-m", + "build", + "--no-isolation", + "--sdist", + "--wheel", + "--outdir", + str(output), + str(ROOT), + cwd=Path(temporary), + ) environment = Path(temporary) / "venv" venv.EnvBuilder(with_pip=True).create(environment) @@ -28,7 +42,10 @@ def run(*args: str, cwd: Path = ROOT) -> None: if len(wheels) != 1: raise RuntimeError(f"expected exactly one CueMap wheel, found {len(wheels)}") wheel = wheels[0] - run(str(python), "-m", "pip", "install", "--no-deps", str(wheel), cwd=Path(temporary)) + run(str(python), "-m", "pip", "install", str(wheel), "pytest", "pytest-asyncio", cwd=Path(temporary)) + tests = Path(temporary) / "tests" + shutil.copytree(ROOT / "tests", tests, ignore=shutil.ignore_patterns("__pycache__")) + run(str(python), "-m", "pytest", "--import-mode=importlib", "-q", str(tests), cwd=Path(temporary)) run( str(python), "-c", diff --git a/tests/test_client.py b/tests/test_client.py index f8e3daa..9250b63 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import json import httpx +import pytest from cuemap import CueMap from cuemap import embedded @@ -16,7 +17,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = CueMap(project_id="hermes-main") client.client.close() client.client = httpx.Client( - base_url="http://localhost:8080", + base_url="http://localhost:8735", transport=httpx.MockTransport(handler), ) @@ -41,7 +42,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = CueMap(project_id="semantic-test") client.client.close() client.client = httpx.Client( - base_url="http://localhost:8080", + base_url="http://localhost:8735", transport=httpx.MockTransport(handler), ) @@ -77,7 +78,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = CueMap(project_id="intent-test") client.client.close() client.client = httpx.Client( - base_url="http://localhost:8080", + base_url="http://localhost:8735", transport=httpx.MockTransport(handler), ) @@ -104,7 +105,7 @@ def handler(request: httpx.Request) -> httpx.Response: client = CueMap(project_id="repo-test") client.client.close() client.client = httpx.Client( - base_url="http://localhost:8080", + base_url="http://localhost:8735", transport=httpx.MockTransport(handler), ) @@ -128,12 +129,136 @@ def handler(request: httpx.Request) -> httpx.Response: assert requests[3]["body"]["embeddings"] == [[0.1, 0.2], [0.3, 0.4]] +def test_project_lifecycle_methods_match_engine_routes(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + return httpx.Response(200, json={"status": "ok", "loaded": request.url.path.endswith("/load")}) + + client = CueMap(project_id="lifecycle-test") + client.client.close() + client.client = httpx.Client( + base_url="http://localhost:8735", + transport=httpx.MockTransport(handler), + ) + + assert client.load_project("repo-one")["loaded"] is True + assert client.save_project("repo-one")["status"] == "ok" + assert client.unload_project("repo-one")["loaded"] is False + assert requests == [ + ("POST", "/projects/repo-one/load"), + ("POST", "/projects/repo-one/save"), + ("POST", "/projects/repo-one/unload"), + ] + + +@pytest.mark.asyncio +async def test_async_project_lifecycle_methods_match_engine_routes(): + requests = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + return httpx.Response(200, json={"status": "ok"}) + + from cuemap import AsyncCueMap + + client = AsyncCueMap(project_id="async-lifecycle-test") + await client.client.aclose() + client.client = httpx.AsyncClient( + base_url="http://localhost:8735", + transport=httpx.MockTransport(handler), + ) + + await client.load_project("repo-two") + await client.save_project("repo-two") + await client.unload_project("repo-two") + await client.close() + + assert requests == [ + ("POST", "/projects/repo-two/load"), + ("POST", "/projects/repo-two/save"), + ("POST", "/projects/repo-two/unload"), + ] + + +def test_project_package_methods_match_engine_routes(): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path, request.content)) + if request.url.path.endswith("/pack"): + return httpx.Response(200, content=b"CUEMAP01package") + return httpx.Response(200, json={"status": "ok"}) + + client = CueMap(project_id="package-test") + client.client.close() + client.client = httpx.Client( + base_url="http://localhost:8735", + transport=httpx.MockTransport(handler), + ) + + package = client.pack_project("repo-package") + assert package == b"CUEMAP01package" + client.load_project_package(package) + client.push_project("repo-package", "s3://bucket/team/") + client.pull_project("s3://bucket/team/repo-package.cuemap") + client.sync_project("repo-package", "s3://bucket/team-sync") + + assert [(method, path) for method, path, _ in requests] == [ + ("POST", "/projects/repo-package/pack"), + ("POST", "/projects/load"), + ("POST", "/projects/repo-package/push"), + ("POST", "/projects/pull"), + ("POST", "/projects/repo-package/sync"), + ] + assert requests[1][2] == package + assert json.loads(requests[2][2]) == {"destination": "s3://bucket/team/"} + assert json.loads(requests[3][2]) == {"source": "s3://bucket/team/repo-package.cuemap"} + assert json.loads(requests[4][2]) == {"remote": "s3://bucket/team-sync"} + + +@pytest.mark.asyncio +async def test_async_project_package_methods_match_engine_routes(): + requests = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append((request.method, request.url.path)) + if request.url.path.endswith("/pack"): + return httpx.Response(200, content=b"CUEMAP01async") + return httpx.Response(200, json={"status": "ok"}) + + from cuemap import AsyncCueMap + + client = AsyncCueMap(project_id="async-package-test") + await client.client.aclose() + client.client = httpx.AsyncClient( + base_url="http://localhost:8735", + transport=httpx.MockTransport(handler), + ) + + package = await client.pack_project("repo-package-async") + await client.load_project_package(package) + await client.push_project("repo-package-async", "s3://bucket/team/") + await client.pull_project("s3://bucket/team/repo-package-async.cuemap") + await client.sync_project("repo-package-async", "s3://bucket/team-sync") + await client.close() + + assert requests == [ + ("POST", "/projects/repo-package-async/pack"), + ("POST", "/projects/load"), + ("POST", "/projects/repo-package-async/push"), + ("POST", "/projects/pull"), + ("POST", "/projects/repo-package-async/sync"), + ] + + def test_embedded_runtime_attaches_without_owning_process(monkeypatch): monkeypatch.setattr(embedded, "_inspect_engine", lambda _url, _api_key=None: "cuemap") - runtime = embedded.EmbeddedCueMap.start(url="http://localhost:8080/") + runtime = embedded.EmbeddedCueMap.start(url="http://localhost:8735/") - assert runtime.url == "http://localhost:8080" + assert runtime.url == "http://localhost:8735" assert runtime.owned is False @@ -142,3 +267,40 @@ def test_explicit_binary_resolution(tmp_path): executable.write_text("test") assert embedded.resolve_cuemap_binary(str(executable)) == str(executable) + + +def test_preview_recall_sync_and_async(): + import asyncio + from cuemap import AsyncCueMap, RecallPreviewResult + + def handler(request): + payload = json.loads(request.content) + assert payload['response_mode'] == 'preview' + assert payload['preview_chars'] == 100 + return httpx.Response(200, json={'response_mode': 'preview', 'results': [{ + 'memory_id': 1, 'preview': 'excerpt', 'content_truncated': True, + 'content_length': 500, 'score': 1, 'intersection_count': 1, + 'recency_score': 1, 'reinforcement_score': 0, + }]}) + + client = CueMap() + client.client.close() + client.client = httpx.Client(base_url='http://localhost:8735', transport=httpx.MockTransport(handler)) + try: + hit = client.recall('discovery', response_mode='preview', preview_chars=100)[0] + assert isinstance(hit, RecallPreviewResult) + assert hit.preview == 'excerpt' and hit.content is None + finally: + client.client.close() + + async def check(): + client = AsyncCueMap() + await client.client.aclose() + client.client = httpx.AsyncClient(base_url='http://localhost:8735', transport=httpx.MockTransport(handler)) + try: + hit = (await client.recall('discovery', response_mode='preview', preview_chars=100))[0] + assert isinstance(hit, RecallPreviewResult) + assert hit.content_truncated and hit.content is None + finally: + await client.client.aclose() + asyncio.run(check()) diff --git a/tests/test_embedded.py b/tests/test_embedded.py new file mode 100644 index 0000000..ce1c074 --- /dev/null +++ b/tests/test_embedded.py @@ -0,0 +1,77 @@ +import subprocess +from pathlib import Path +from unittest.mock import Mock + +import pytest +from cuemap import embedded + + +@pytest.mark.parametrize("system,machine,expected", [ + ("Windows", "AMD64", "win32-x64"), ("Linux", "aarch64", "linux-arm64"), + ("Linux", "x86_64", "linux-x64"), ("Darwin", "arm64", "darwin-arm64"), + ("Darwin", "x86_64", "darwin-x64"), +]) +def test_native_platform_names(monkeypatch, system, machine, expected): + monkeypatch.setattr(embedded.platform, "system", lambda: system) + monkeypatch.setattr(embedded.platform, "machine", lambda: machine) + assert embedded._platform_package() == "@cuemap-dev/engine-" + expected + + +def test_unsupported_architecture_is_not_misidentified(monkeypatch): + monkeypatch.setattr(embedded.platform, "machine", lambda: "riscv64") + with pytest.raises(RuntimeError, match="Unsupported"): + embedded._platform_package() + + +def test_windows_wrapper_launch_and_auth_environment(monkeypatch, tmp_path): + wrapper = tmp_path / "cuemap" + wrapper.write_text("// wrapper") + monkeypatch.setattr(embedded.platform, "system", lambda: "Windows") + monkeypatch.setattr(embedded.shutil, "which", lambda name: "node.exe") + monkeypatch.setattr(embedded, "_inspect_engine", Mock(side_effect=["closed", "cuemap"])) + process = Mock() + process.poll.return_value = None + popen = Mock(return_value=process) + monkeypatch.setattr(embedded.subprocess, "Popen", popen) + engine = embedded.EmbeddedCueMap.start(bin_path=str(wrapper), api_key="test-secret", env={"CUEMAP_HOST": "0.0.0.0"}) + assert popen.call_args.args[0][:2] == ["node.exe", str(wrapper)] + assert popen.call_args.kwargs["env"]["CUEMAP_API_KEY"] == "test-secret" + assert popen.call_args.kwargs["env"]["CUEMAP_HOST"] == "127.0.0.1" + engine.stop() + process.wait.assert_called_once() + + +def test_startup_timeout_reaps_process(monkeypatch, tmp_path): + executable = tmp_path / "cuemap.exe" + executable.write_text("") + monkeypatch.setattr(embedded, "_inspect_engine", lambda *args: "closed") + process = Mock() + process.poll.return_value = None + process.wait.side_effect = [subprocess.TimeoutExpired("cuemap", 0), 0] + monkeypatch.setattr(embedded.subprocess, "Popen", Mock(return_value=process)) + with pytest.raises(TimeoutError): + embedded.EmbeddedCueMap.start(bin_path=str(executable), startup_timeout=0, shutdown_timeout=0) + process.terminate.assert_called_once() + process.kill.assert_called_once() + assert process.wait.call_count == 2 + + +def test_windows_global_npm_layout_resolves_native_executable(monkeypatch, tmp_path): + npm_dir = tmp_path / 'npm' + npm_cli = npm_dir / 'node_modules' / 'npm' / 'bin' / 'npm-cli.js' + npm_cli.parent.mkdir(parents=True) + npm_cli.write_text('// npm CLI') + package_root = tmp_path / 'global' / 'node_modules' + native = package_root / '@cuemap-dev' / 'engine-win32-x64' / 'bin' / 'cuemap-native.exe' + native.parent.mkdir(parents=True) + native.write_bytes(b'test fixture') + monkeypatch.delenv('CUEMAP_BIN', raising=False) + monkeypatch.setattr(embedded.platform, 'system', lambda: 'Windows') + monkeypatch.setattr(embedded.platform, 'machine', lambda: 'AMD64') + monkeypatch.setattr(embedded.shutil, 'which', lambda name: { + 'cuemap': str(npm_dir / 'cuemap.cmd'), 'npm': str(npm_dir / 'npm.cmd'), 'node': 'node.exe', + }.get(name)) + run = Mock(return_value=Mock(stdout=str(package_root))) + monkeypatch.setattr(embedded.subprocess, 'run', run) + assert embedded.resolve_cuemap_binary() == str(native) + assert run.call_args.args[0] == ['node.exe', str(npm_cli), 'root', '--global'] diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index ffd6e35..3565b0c 100644 --- a/tests/test_engine_integration.py +++ b/tests/test_engine_integration.py @@ -34,7 +34,7 @@ def running_engine(tmp_path_factory): if not binary: binary = resolve_cuemap_binary() except FileNotFoundError as exc: - pytest.skip(str(exc)) + pytest.fail(str(exc)) data_dir = tmp_path_factory.mktemp("cuemap-e2e-data") runtime = EmbeddedCueMap.start( @@ -43,6 +43,7 @@ def running_engine(tmp_path_factory): startup_timeout=30.0, env={ "CUEMAP_DATA_DIR": str(data_dir), + "CUEMAP_HOME": str(data_dir / "config"), "CUEMAP_SEMANTIC_ENCODER_ENABLED": "false", "CUEMAP_SNAPSHOT_INTERVAL_SECONDS": "3600", },