diff --git a/.env.example b/.env.example index 21bf35060..f5a76bd5e 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,11 @@ # These are the ONLY variables you need to start PowerMem. Everything else has # a safe default, so the system runs out of the box: # -# - Database -> OceanBase provider with no host configured, which boots -# embedded seekdb on disk at ./seekdb_data (same engine, -# same SQL surface, no separate server). Set OCEANBASE_HOST -# in `.env.example.full` to point at a remote cluster. +# - Database -> platform-aware default. Linux with embedded SeekDB available +# uses OceanBase/SeekDB on disk; other zero-config platforms +# use SQLite at ./data/powermem_dev.db for basic CRUD/search. +# Use OceanBase/SeekDB for Graph Store, sub_stores, sparse +# vectors, and SkillStore. # - Embedder -> built-in local all-MiniLM-L6-v2 (no API key required; # model auto-downloads to ~/.cache on first use) # - Reranker / graph store / telemetry / audit -> sensible defaults / off diff --git a/.env.example.full b/.env.example.full index dac590443..168898ce1 100644 --- a/.env.example.full +++ b/.env.example.full @@ -15,9 +15,10 @@ # cp .env.example.full .env # # Notes on defaults: -# - Database: DATABASE_PROVIDER=oceanbase with OCEANBASE_HOST left empty, -# which boots embedded seekdb on disk (same engine, no separate server). -# Set OCEANBASE_HOST to point at a real OceanBase cluster instead. +# - Database: platform-aware. Linux with embedded SeekDB available defaults +# to OceanBase/SeekDB on disk; other zero-config platforms default to +# SQLite for basic local memory CRUD/search. Use OceanBase/SeekDB for the +# full feature stack. # - Embedder: built-in `all-MiniLM-L6-v2` (384 dims) running locally with no # API key. Setting EMBEDDING_PROVIDER below switches to a cloud / self- # hosted embedder instead. @@ -36,14 +37,14 @@ # ============================================================================= # # DATABASE_PROVIDER — which storage engine PowerMem talks to. -# Recommended: oceanbase (the OceanBase backend covers both deployment -# shapes: leave OCEANBASE_HOST empty for embedded -# seekdb on disk — zero ops, no separate server — +# Recommended: oceanbase (full capability stack; leave OCEANBASE_HOST empty +# for embedded SeekDB on supported Linux installs, # or set OCEANBASE_HOST to point at a remote # OceanBase cluster) -# Other options: sqlite (smallest footprint, dev-only), +# Leave unset to use the platform-aware default. +# Other options: sqlite (smallest footprint, basic local CRUD/search), # postgres / pgvector (if your stack already runs PostgreSQL) -DATABASE_PROVIDER=oceanbase +# DATABASE_PROVIDER=oceanbase # ----------------------------------------------------------------------------- # OceanBase — used when DATABASE_PROVIDER=oceanbase. The same provider covers diff --git a/apps/claude-code-plugin/SETUP.md b/apps/claude-code-plugin/SETUP.md index 141a31a13..41abe52d3 100644 --- a/apps/claude-code-plugin/SETUP.md +++ b/apps/claude-code-plugin/SETUP.md @@ -254,8 +254,9 @@ writing. Never silently patch `.env`.** with LLM_PROVIDER / LLM_API_KEY or LLM_AUTH_TOKEN / LLM_MODEL set to real values (not placeholders like `your_api_key_here`), REUSE it — skip directly to step 3a/3b. Only collect - what is missing. Use zero-config defaults for everything else (storage = embedded - seekdb, embedder = local all-MiniLM-L6-v2) unless I say otherwise. + what is missing. Use zero-config defaults for everything else (storage = + platform-aware: embedded SeekDB on supported Linux installs, otherwise SQLite + basic mode; embedder = local all-MiniLM-L6-v2) unless I say otherwise. **2a. Auto-detect or manual?** Use AskUserQuestion (single-select): @@ -359,7 +360,9 @@ writing. Never silently patch `.env`.** ``` ⚠️ Both extras are required: `[server]` adds fastapi/uvicorn and fastmcp for the HTTP API plus MCP transports; - `[seekdb]` adds the embedded seekdb storage backend (default). + `[seekdb]` adds the embedded seekdb storage backend. Platforms where + embedded SeekDB is unavailable default to SQLite basic mode; use + OceanBase/SeekDB for the full capability stack. - Immediately after `uv pip install`, detect which Python interpreter was used. Read the shebang from the freshly-installed `powermem-server` entry point — this is the only reliable way to guarantee that the model-download script, the uv call diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json new file mode 100644 index 000000000..ba729f4c9 --- /dev/null +++ b/apps/vscode-extension/package-lock.json @@ -0,0 +1,59 @@ +{ + "name": "powermem-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "powermem-vscode", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^18.x", + "@types/vscode": "^1.104.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.104.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/dashboard/src/components/system-health-card.tsx b/dashboard/src/components/system-health-card.tsx index 51534f98e..392684045 100644 --- a/dashboard/src/components/system-health-card.tsx +++ b/dashboard/src/components/system-health-card.tsx @@ -94,6 +94,12 @@ export function SystemHealthCard({ status }: SystemHealthCardProps) { const systemStatus = getStatusDisplay(status.status); const StatusIcon = systemStatus.icon; + const storageCapabilities = status.storage_capabilities; + const storageLimitations = storageCapabilities?.limitations ?? []; + const showStorageWarning = + status.storage_type?.toLowerCase() === "sqlite" || + (storageCapabilities?.provider === "sqlite" && + storageCapabilities.full_stack_available === false); return ( @@ -143,6 +149,25 @@ export function SystemHealthCard({ status }: SystemHealthCardProps) { )} + {showStorageWarning && ( +
+
+ +
+

+ {t("dashboard.systemHealth.sqliteWarningTitle")} +

+

{t("dashboard.systemHealth.sqliteWarningDescription")}

+ {storageLimitations.length > 0 && ( +

+ {storageLimitations.join("; ")} +

+ )} +
+
+
+ )} + {/* Dependencies Table */} {status.dependencies && Object.keys(status.dependencies).length > 0 && ( diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index cecdccda1..94f0b5eda 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -48,7 +48,9 @@ "statusDown": "Down", "latency": "latency", "noStatus": "No status available", - "loading": "Loading system status..." + "loading": "Loading system status...", + "sqliteWarningTitle": "SQLite basic mode", + "sqliteWarningDescription": "PowerMem is running on SQLite. Basic memory CRUD and search are available, but the full capability stack requires OceanBase or embedded SeekDB." }, "charts": { "growthTrend": "Growth Trend", diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index 2be4d71fa..4bdf3fb10 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -48,7 +48,9 @@ "statusDown": "故障", "latency": "延迟", "noStatus": "暂无状态信息", - "loading": "加载系统状态中..." + "loading": "加载系统状态中...", + "sqliteWarningTitle": "SQLite 基础模式", + "sqliteWarningDescription": "PowerMem 当前使用 SQLite。基础记忆 CRUD 与搜索可用,完整能力栈需要切换到 OceanBase 或嵌入式 SeekDB。" }, "charts": { "growthTrend": "增长趋势", diff --git a/dashboard/src/types/api.ts b/dashboard/src/types/api.ts index 17d08240e..85b42c04d 100644 --- a/dashboard/src/types/api.ts +++ b/dashboard/src/types/api.ts @@ -15,6 +15,15 @@ export interface SystemStatus { version: string; storage_type?: string; llm_provider?: string; + memory_service_ready?: boolean; + startup_error?: string; + storage_capabilities?: { + provider?: string; + defaulted: boolean; + full_stack_available: boolean; + limitations: string[]; + recommendation?: string; + }; uptime_seconds: number; started_at: string; dependencies: Record; diff --git a/docs/guides/0003-configuration.md b/docs/guides/0003-configuration.md index 9acb47d1f..1d33ee242 100644 --- a/docs/guides/0003-configuration.md +++ b/docs/guides/0003-configuration.md @@ -91,17 +91,17 @@ memory = Memory(config=config) ## 1. Database Configuration (Required) -PowerMem requires a database provider to store memories and vectors. Choose one of the supported providers: SQLite (development), OceanBase (production), or PostgreSQL. +PowerMem requires a database provider to store memories and vectors. The zero-config default is platform-aware: Linux with embedded SeekDB available uses the OceanBase provider in embedded mode; other platforms fall back to SQLite for basic local memory CRUD/search. Choose OceanBase/SeekDB for the full capability stack, or PostgreSQL/pgvector when your deployment already runs PostgreSQL. ### Common Database Settings | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| -| `DATABASE_PROVIDER` | string | Yes | `sqlite` | Database provider to use. Options: `sqlite`, `oceanbase`, `postgres` | +| `DATABASE_PROVIDER` | string | No | platform-aware | Database provider to use. Options: `sqlite`, `oceanbase`, `postgres` | ### SQLite Configuration -SQLite is the default database provider, recommended for development and single-user applications. +SQLite is the fallback default when embedded SeekDB is unavailable. It is recommended for development and single-user basic memory CRUD/search. Graph Store, sub_stores, sparse vector search, and SkillStore require OceanBase or embedded SeekDB. | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| @@ -163,15 +163,15 @@ config = { ### OceanBase Configuration -OceanBase is recommended for production deployments and enterprise applications with high-scale requirements. +OceanBase is recommended for production deployments and for the full PowerMem capability stack. With `OCEANBASE_HOST` empty, the OceanBase provider uses embedded SeekDB when the native dependency is available. Set `OCEANBASE_HOST` for a remote OceanBase cluster. | Configuration | Type | Required | Default | Description | |--------------|------|----------|---------|-------------| -| `OCEANBASE_HOST` | string | Yes* | `127.0.0.1` | OceanBase server hostname or IP address. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_PORT` | integer | Yes* | `2881` | OceanBase server port. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_USER` | string | Yes* | `root` | Database username. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_PASSWORD` | string | Yes* | - | Database password. Required when `DATABASE_PROVIDER=oceanbase` | -| `OCEANBASE_DATABASE` | string | Yes* | `powermem` | Database name. Required when `DATABASE_PROVIDER=oceanbase` | +| `OCEANBASE_HOST` | string | No* | empty | OceanBase server hostname or IP address. Empty means embedded SeekDB mode; remote mode requires a host | +| `OCEANBASE_PORT` | integer | Yes* | `2881` | OceanBase server port. Required in remote mode | +| `OCEANBASE_USER` | string | Yes* | `root@test` | Database username. Required in remote mode | +| `OCEANBASE_PASSWORD` | string | No | - | Database password. Required if your remote cluster needs one | +| `OCEANBASE_DATABASE` | string | Yes* | `test` | Database name | | `OCEANBASE_COLLECTION` | string | No | `memories` | Collection/table name for storing memories | | `OCEANBASE_INDEX_TYPE` | string | No | `IVF_FLAT` | Vector index type. Options: `IVF_FLAT`, `HNSW`, etc. | | `OCEANBASE_VECTOR_METRIC_TYPE` | string | No | `cosine` | Vector similarity metric. Options: `cosine`, `euclidean`, `dot_product` | diff --git a/src/powermem/cli/commands/config.py b/src/powermem/cli/commands/config.py index 9a953e002..37db869b4 100644 --- a/src/powermem/cli/commands/config.py +++ b/src/powermem/cli/commands/config.py @@ -23,6 +23,13 @@ print_warning, print_info, ) +from powermem.platform_defaults import ( + database_provider_explicitly_configured, + default_database_provider, + embedded_seekdb_available, + embedded_seekdb_unavailable_message, + sqlite_capability_warning, +) logger = logging.getLogger(__name__) from ..utils.envfile import read_env_file, update_env_file @@ -295,11 +302,22 @@ def _validate_loaded_config(config: Dict[str, Any], strict: bool) -> Dict[str, A provider = vector_store_config.get("provider", "") inner_config = vector_store_config.get("config", {}) if provider == "oceanbase": - required_fields = ["host", "port", "user", "db_name"] conn_args = inner_config.get("connection_args", {}) - for field in required_fields: - if not conn_args.get(field): - errors.append(f"OceanBase connection missing: {field}") + host = conn_args.get("host") or inner_config.get("host") + if host: + required_fields = ["port", "user", "db_name"] + for field in required_fields: + if not (conn_args.get(field) or inner_config.get(field)): + errors.append(f"OceanBase connection missing: {field}") + elif not embedded_seekdb_available(): + errors.append(embedded_seekdb_unavailable_message()) + elif provider == "sqlite": + warning = sqlite_capability_warning( + provider, + defaulted=not database_provider_explicitly_configured(), + ) + if warning: + warnings.append(warning) elif provider in ("postgres", "pgvector"): required_fields = ["host", "port", "user", "dbname"] for field in required_fields: @@ -795,7 +813,7 @@ def _discover_env_example(start_dir: Path, max_parent_levels: int = 8) -> Option def _wizard_database(existing: Dict[str, str]) -> Dict[str, str]: updates: Dict[str, str] = {} - provider_default = existing.get("DATABASE_PROVIDER") or "sqlite" + provider_default = existing.get("DATABASE_PROVIDER") or default_database_provider() provider = click.prompt( "Database provider", type=click.Choice(["oceanbase", "postgres", "sqlite"], case_sensitive=False), @@ -909,7 +927,7 @@ def _wizard_database_quickstart(existing: Dict[str, str]) -> Dict[str, str]: This intentionally avoids optional knobs (WAL/timeout/collection/etc.). """ updates: Dict[str, str] = {} - provider_default = existing.get("DATABASE_PROVIDER") or "sqlite" + provider_default = existing.get("DATABASE_PROVIDER") or default_database_provider() provider = click.prompt( "Database provider", type=click.Choice(["sqlite", "oceanbase", "postgres"], case_sensitive=False), @@ -1278,7 +1296,7 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool emb_updates = _wizard_embedder_quickstart(existing, llm_updates=llm_updates) updates.update(emb_updates) - db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or "sqlite" + db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or default_database_provider() dims = emb_updates.get("EMBEDDING_DIMS") if dims: updates.update(_sync_vector_dims_quickstart(db_provider=db_provider, dims=dims)) @@ -1293,7 +1311,7 @@ def init_cmd(ctx: CLIContext, env_file: Optional[str], dry_run: bool, test: bool emb_updates = _wizard_embedder(existing, llm_updates=llm_updates) updates.update(emb_updates) - db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or "sqlite" + db_provider = updates.get("DATABASE_PROVIDER") or existing.get("DATABASE_PROVIDER") or default_database_provider() dims = emb_updates.get("EMBEDDING_DIMS") if dims: updates.update(_maybe_sync_vector_dims(db_provider=db_provider, dims=dims)) diff --git a/src/powermem/config_loader.py b/src/powermem/config_loader.py index d087eff8d..5d06d08e0 100644 --- a/src/powermem/config_loader.py +++ b/src/powermem/config_loader.py @@ -7,6 +7,7 @@ import os import warnings +import logging from typing import Any, Dict, Optional from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -17,10 +18,18 @@ from powermem.integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig from powermem.integrations.llm.config.base import BaseLLMConfig from powermem.integrations.llm.config.noop import NoopConfig # noqa: F401 - register noop provider +from powermem.platform_defaults import ( + choose_default_database_provider, + default_database_provider, + sqlite_capability_warning, +) from powermem.settings import _DEFAULT_ENV_FILE, settings_config from powermem.utils.utils import detect_system_timezone +logger = logging.getLogger(__name__) + + def _load_dotenv_if_available() -> None: """ Load env files into os.environ before BaseSettings / Memory read configuration. @@ -125,7 +134,7 @@ class DatabaseSettings(_BasePowermemSettings): model_config = settings_config() provider: str = Field( - default="oceanbase", + default_factory=default_database_provider, validation_alias=AliasChoices("DATABASE_PROVIDER"), ) @@ -139,11 +148,16 @@ def to_config(self) -> Dict[str, Any]: from powermem.storage.config.base import BaseVectorStoreConfig db_provider = self.provider.lower() + decision = choose_default_database_provider() # Handle postgres alias if db_provider == "postgres": db_provider = "pgvector" + warning = sqlite_capability_warning(db_provider, defaulted=decision.defaulted) + if warning and decision.defaulted: + logger.warning("Defaulting storage provider to SQLite: %s", warning) + # 1. Get provider config class from registry config_cls = ( BaseVectorStoreConfig.get_provider_config_cls(db_provider) @@ -729,7 +743,7 @@ def load_config_from_env() -> Dict[str, Any]: class CreateConfigOptions(BaseModel): model_config = ConfigDict(extra="forbid") - database_provider: str = "oceanbase" + database_provider: str = Field(default_factory=default_database_provider) llm_provider: str = "qwen" embedding_provider: str = "qwen" database_config: Dict[str, Any] = Field(default_factory=dict) @@ -749,7 +763,7 @@ class CreateConfigOptions(BaseModel): def create_config( - database_provider: str = "oceanbase", + database_provider: Optional[str] = None, llm_provider: str = "qwen", embedding_provider: str = "qwen", database_config: Optional[Dict[str, Any]] = None, @@ -812,7 +826,7 @@ def create_config( stacklevel=2, ) options = CreateConfigOptions( - database_provider=database_provider, + database_provider=database_provider or default_database_provider(), llm_provider=llm_provider, embedding_provider=embedding_provider, database_config=database_config or {}, diff --git a/src/powermem/configs.py b/src/powermem/configs.py index 0c1252212..cb622f72d 100644 --- a/src/powermem/configs.py +++ b/src/powermem/configs.py @@ -23,9 +23,21 @@ OceanBaseConfig, OceanBaseGraphConfig, # noqa: F401 — keeps OceanBase graph provider registered ) +from powermem.platform_defaults import default_database_provider from powermem.integrations.rerank.config.base import BaseRerankConfig +def _default_vector_store_config() -> BaseVectorStoreConfig: + provider = default_database_provider() + if provider == "sqlite": + return SQLiteConfig() + if provider in ("postgres", "pgvector"): + from powermem.storage.config.pgvector import PGVectorConfig + + return PGVectorConfig() + return OceanBaseConfig() + + class IntelligentMemoryConfig(BaseModel): """Configuration for intelligent memory management with Ebbinghaus algorithm.""" @@ -251,13 +263,13 @@ class MemoryConfig(BaseModel): vector_store: BaseVectorStoreConfig = Field( description=( - "Configuration for the vector store. Defaults to the OceanBase " - "provider with an empty host, which boots embedded seekdb on " - "disk (no separate server) so PowerMem starts with zero ops; " - "set OCEANBASE_HOST to point at a remote OceanBase cluster, or " - "switch the provider to sqlite / postgres." + "Configuration for the vector store. Defaults are platform-aware: " + "explicit DATABASE_PROVIDER wins; OCEANBASE_HOST selects remote " + "OceanBase; Linux with embedded SeekDB capability uses OceanBase " + "embedded mode; otherwise PowerMem falls back to SQLite for basic " + "local memory CRUD/search." ), - default_factory=OceanBaseConfig, + default_factory=_default_vector_store_config, ) llm: BaseLLMConfig = Field( description="Configuration for the language model", diff --git a/src/powermem/core/async_memory.py b/src/powermem/core/async_memory.py index 176d19539..92143c4a7 100644 --- a/src/powermem/core/async_memory.py +++ b/src/powermem/core/async_memory.py @@ -16,6 +16,7 @@ from .base import MemoryBase from ..configs import MemoryConfig +from ..platform_defaults import default_database_provider from ..storage.factory import VectorStoreFactory, GraphStoreFactory from ..storage.adapter import StorageAdapter, SubStorageAdapter from ..intelligence.manager import IntelligenceManager @@ -83,13 +84,13 @@ def _auto_convert_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } converted.pop("database", None) elif "vector_store" not in converted: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } @@ -145,7 +146,7 @@ def __init__( self.agent_id = agent_id # Extract providers from config with fallbacks - self.storage_type = storage_type or self._get_provider('vector_store', 'oceanbase') + self.storage_type = storage_type or self._get_provider('vector_store', default_database_provider()) self.llm_provider = llm_provider or self._get_provider('llm', 'mock') self.embedding_provider = embedding_provider or self._get_provider('embedder', 'mock') diff --git a/src/powermem/core/memory.py b/src/powermem/core/memory.py index 28288e5dc..b5b4c8f90 100644 --- a/src/powermem/core/memory.py +++ b/src/powermem/core/memory.py @@ -17,6 +17,7 @@ from .base import MemoryBase from ..configs import MemoryConfig +from ..platform_defaults import default_database_provider from ..integrations.embeddings.config.sparse_base import BaseSparseEmbedderConfig from ..storage.factory import VectorStoreFactory, GraphStoreFactory from ..storage.adapter import StorageAdapter, SubStorageAdapter @@ -481,13 +482,13 @@ def _auto_convert_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } converted.pop("database", None) elif "vector_store" not in converted: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } @@ -584,7 +585,7 @@ def __init__( logger.debug(f"Timezone set from config: {timezone_config}") # Extract providers from config with fallbacks - self.storage_type = storage_type or self._get_provider('vector_store', 'oceanbase') + self.storage_type = storage_type or self._get_provider('vector_store', default_database_provider()) self.llm_provider = llm_provider or self._get_provider('llm', 'mock') self.embedding_provider = embedding_provider or self._get_provider('embedder', 'mock') diff --git a/src/powermem/core/setup.py b/src/powermem/core/setup.py index 46b5c2f28..9e86c68f4 100644 --- a/src/powermem/core/setup.py +++ b/src/powermem/core/setup.py @@ -14,6 +14,7 @@ from pydantic import Field, field_validator from pydantic_settings import BaseSettings +from ..platform_defaults import default_database_provider from ..settings import settings_config logger = logging.getLogger(__name__) @@ -138,12 +139,12 @@ def _convert_legacy_to_mem_config(config: Dict[str, Any]) -> Dict[str, Any]: if "database" in config: db_config = config["database"] converted["vector_store"] = { - "provider": db_config.get("provider", "oceanbase"), + "provider": db_config.get("provider", default_database_provider()), "config": db_config.get("config", {}) } else: converted["vector_store"] = { - "provider": "oceanbase", + "provider": default_database_provider(), "config": {} } diff --git a/src/powermem/integrations/llm/factory.py b/src/powermem/integrations/llm/factory.py index fb76a0544..a9a2359c0 100644 --- a/src/powermem/integrations/llm/factory.py +++ b/src/powermem/integrations/llm/factory.py @@ -49,7 +49,7 @@ def create(cls, provider_name: str, config: Optional[Union[BaseLLMConfig, Dict]] # 1. Get class_path from registry class_path = BaseLLMConfig.get_provider_class_path(provider_name) if not class_path: - raise ValueError(f"Unsupported Llm provider: {provider_name}") + raise ValueError(f"Unsupported LLM provider: {provider_name}") # 2. Get config_cls from registry config_cls = BaseLLMConfig.get_provider_config_cls(provider_name) or BaseLLMConfig diff --git a/src/powermem/intelligence/importance_evaluator.py b/src/powermem/intelligence/importance_evaluator.py index 44fc424ca..8b8a1ef6f 100644 --- a/src/powermem/intelligence/importance_evaluator.py +++ b/src/powermem/intelligence/importance_evaluator.py @@ -187,24 +187,24 @@ def _llm_based_evaluation( {"role": "system", "content": self.prompts.get_system_prompt()}, {"role": "user", "content": prompt} ] - + # Call LLM for evaluation response = self.llm.generate_response(messages) - + # Parse the response to extract importance score importance_score = self._parse_importance_response(response) - + if importance_score is None: logger.warning( "LLM response could not be parsed reliably, " "falling back to rule-based evaluation" ) return self._rule_based_evaluation(content, metadata, context) - + logger.debug(f"LLM evaluated importance: {importance_score}") - + return importance_score - + except Exception as e: logger.error(f"LLM-based evaluation failed: {e}, falling back to rule-based") return self._rule_based_evaluation(content, metadata, context) diff --git a/src/powermem/platform_defaults.py b/src/powermem/platform_defaults.py new file mode 100644 index 000000000..995a909b2 --- /dev/null +++ b/src/powermem/platform_defaults.py @@ -0,0 +1,214 @@ +"""Platform-aware defaults for local PowerMem startup.""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict + + +SQLITE_LIMITATIONS = [ + "Graph Store is not available", + "sub_stores routing is not available", + "Sparse vector search is not available", + "SkillStore requires OceanBase or embedded SeekDB", +] + +SQLITE_RECOMMENDATION = ( + "SQLite is suitable for local development and basic memory CRUD/search. " + 'For the full PowerMem capability stack, use embedded SeekDB on Linux with ' + 'pip install "powermem[seekdb]", or configure DATABASE_PROVIDER=oceanbase ' + "with OCEANBASE_HOST for a remote OceanBase cluster." +) + + +@dataclass(frozen=True) +class StorageDefaultDecision: + provider: str + reason: str + defaulted: bool + embedded_seekdb_available: bool + + +def _configured_env_files() -> list[str]: + env_files: list[str] = [] + cli_env = os.environ.get("POWERMEM_ENV_FILE") + if cli_env: + env_files.append(os.path.expanduser(os.path.expandvars(cli_env.strip()))) + + try: + from powermem.settings import _DEFAULT_ENV_FILE + except Exception: + _DEFAULT_ENV_FILE = None + + if _DEFAULT_ENV_FILE: + env_files.append(str(_DEFAULT_ENV_FILE)) + + seen = set() + result = [] + for path in env_files: + if not path or path in seen: + continue + seen.add(path) + result.append(path) + return result + + +def _read_env_file_value(path: str, key: str) -> str | None: + env_path = Path(path) + if not env_path.is_file(): + return None + + try: + from dotenv import dotenv_values + + values = dotenv_values(env_path) + for env_key, value in values.items(): + if env_key and env_key.upper() == key.upper() and value: + return value + return None + except Exception: + pass + + try: + lines = env_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + env_key, value = stripped.split("=", 1) + if env_key.strip().upper() != key.upper(): + continue + value = value.strip().strip("'\"") + return value or None + return None + + +def _settings_value(key: str) -> str | None: + for env_key, value in os.environ.items(): + if env_key.upper() == key.upper() and value: + return value + + for env_file in _configured_env_files(): + value = _read_env_file_value(env_file, key) + if value: + return value + return None + + +def database_provider_explicitly_configured() -> bool: + return bool(_settings_value("DATABASE_PROVIDER")) + + +def oceanbase_remote_configured() -> bool: + return bool(_settings_value("OCEANBASE_HOST")) + + +def _module_available(module_name: str) -> bool: + try: + return importlib.util.find_spec(module_name) is not None + except (ImportError, ModuleNotFoundError, ValueError): + return False + + +def embedded_seekdb_available() -> bool: + """Return whether embedded SeekDB can be used on this platform. + + This is intentionally a light capability probe. It avoids constructing an + ObVecClient so configuration loading never creates data files or starts the + embedded engine. + """ + if not sys.platform.startswith("linux"): + return False + return ( + _module_available("pyobvector") + and _module_available("pyseekdb") + and _module_available("pylibseekdb") + ) + + +def choose_default_database_provider() -> StorageDefaultDecision: + configured_provider = _settings_value("DATABASE_PROVIDER") + if configured_provider: + return StorageDefaultDecision( + provider=configured_provider.lower(), + reason="DATABASE_PROVIDER is explicitly configured", + defaulted=False, + embedded_seekdb_available=embedded_seekdb_available(), + ) + + if oceanbase_remote_configured(): + return StorageDefaultDecision( + provider="oceanbase", + reason="OCEANBASE_HOST is configured, using remote OceanBase", + defaulted=True, + embedded_seekdb_available=embedded_seekdb_available(), + ) + + seekdb_available = embedded_seekdb_available() + if seekdb_available: + return StorageDefaultDecision( + provider="oceanbase", + reason="Linux embedded SeekDB capability detected", + defaulted=True, + embedded_seekdb_available=True, + ) + + return StorageDefaultDecision( + provider="sqlite", + reason="embedded SeekDB is not available on this platform, using SQLite", + defaulted=True, + embedded_seekdb_available=False, + ) + + +def default_database_provider() -> str: + return choose_default_database_provider().provider + + +def storage_capabilities(provider: str, defaulted: bool | None = None) -> Dict[str, Any]: + normalized = (provider or "").lower() + if defaulted is None: + defaulted = not database_provider_explicitly_configured() + + if normalized == "sqlite": + return { + "provider": "sqlite", + "defaulted": bool(defaulted), + "full_stack_available": False, + "limitations": list(SQLITE_LIMITATIONS), + "recommendation": SQLITE_RECOMMENDATION, + } + + return { + "provider": normalized or None, + "defaulted": bool(defaulted), + "full_stack_available": normalized == "oceanbase", + "limitations": [], + "recommendation": None, + } + + +def sqlite_capability_warning(provider: str, defaulted: bool | None = None) -> str | None: + capabilities = storage_capabilities(provider, defaulted=defaulted) + if capabilities.get("provider") != "sqlite": + return None + limitations = "; ".join(capabilities["limitations"]) + return f"{capabilities['recommendation']} Limitations in SQLite mode: {limitations}." + + +def embedded_seekdb_unavailable_message() -> str: + return ( + "Embedded SeekDB is not available on this platform or installation. " + f"platform={sys.platform}, pyobvector={_module_available('pyobvector')}, " + f"pyseekdb={_module_available('pyseekdb')}, " + f"pylibseekdb={_module_available('pylibseekdb')}. " + 'Use DATABASE_PROVIDER=sqlite for local basic storage, install "powermem[seekdb]" ' + "on Linux for embedded SeekDB, or set OCEANBASE_HOST for a remote OceanBase cluster." + ) diff --git a/src/powermem/storage/oceanbase/oceanbase.py b/src/powermem/storage/oceanbase/oceanbase.py index 92b80fd42..21f93b836 100644 --- a/src/powermem/storage/oceanbase/oceanbase.py +++ b/src/powermem/storage/oceanbase/oceanbase.py @@ -11,6 +11,10 @@ from typing import Any, Dict, List, Optional from powermem.storage.base import VectorStoreBase, OutputData +from powermem.platform_defaults import ( + embedded_seekdb_available, + embedded_seekdb_unavailable_message, +) from powermem.utils.utils import serialize_datetime, generate_snowflake_id from powermem.utils.oceanbase_util import OceanBaseUtil @@ -217,6 +221,8 @@ def _create_client(self, **kwargs): **kwargs, ) else: + if not embedded_seekdb_available(): + raise RuntimeError(embedded_seekdb_unavailable_message()) ob_path = self.connection_args.get("ob_path", "./seekdb_data") OceanBaseUtil.ensure_embedded_database_exists(ob_path, db_name) self.obvector = ObVecClient(path=ob_path, db_name=db_name) @@ -2450,4 +2456,4 @@ def execute_sql(self, sql: str, params: Optional[Dict[str, Any]] = None) -> List except Exception as e: logger.error(f"Failed to execute SQL: {e}") logger.debug(f"SQL statement: {sql}") - raise \ No newline at end of file + raise diff --git a/src/server/api/v1/agents.py b/src/server/api/v1/agents.py index f4d3ae4bf..9554b5f74 100644 --- a/src/server/api/v1/agents.py +++ b/src/server/api/v1/agents.py @@ -12,6 +12,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import memory_dict_to_response +from ...utils.service_errors import service_unavailable_message router = APIRouter(prefix="/agents", tags=["agents"]) @@ -23,7 +24,7 @@ def get_agent_service(request: Request) -> AgentService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Agent service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Agent"), status_code=503, ) return service diff --git a/src/server/api/v1/memories.py b/src/server/api/v1/memories.py index 160f0b14d..98c05b9ec 100644 --- a/src/server/api/v1/memories.py +++ b/src/server/api/v1/memories.py @@ -28,6 +28,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import memory_dict_to_response +from ...utils.service_errors import service_unavailable_message logger = logging.getLogger("server") @@ -58,7 +59,7 @@ def get_memory_service(request: Request) -> MemoryService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Memory service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Memory"), status_code=503, ) return service diff --git a/src/server/api/v1/search.py b/src/server/api/v1/search.py index 3803016fa..eecc8b4e5 100644 --- a/src/server/api/v1/search.py +++ b/src/server/api/v1/search.py @@ -14,6 +14,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import search_result_to_response +from ...utils.service_errors import service_unavailable_message from .memories import parse_time_range_cutoff router = APIRouter(prefix="/memories", tags=["search"]) @@ -69,7 +70,7 @@ def get_search_service(request: Request) -> SearchService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="Search service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "Search"), status_code=503, ) return service diff --git a/src/server/api/v1/system.py b/src/server/api/v1/system.py index f4fb03d68..f2d74a4fd 100644 --- a/src/server/api/v1/system.py +++ b/src/server/api/v1/system.py @@ -7,7 +7,12 @@ from typing import Optional from datetime import datetime, timezone -from ...models.response import APIResponse, HealthResponse, StatusResponse +from ...models.response import ( + APIResponse, + DependencyStatus, + HealthResponse, + StatusResponse, +) from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...config import config @@ -28,14 +33,19 @@ summary="Health check", description="Check if the API server is healthy (public endpoint, no authentication required)", ) -async def health_check(): +async def health_check(request: Request): """Health check endpoint""" - health = HealthResponse(status="healthy") + ready = bool(getattr(request.app.state, "service_ready", False)) + health = HealthResponse( + status="healthy" if ready else "degraded", + memory_service_ready=ready, + storage_type=getattr(request.app.state, "storage_type", None), + ) return APIResponse( success=True, data=health.model_dump(mode='json'), - message="Service is healthy", + message="Service is healthy" if ready else "HTTP server is running but memory service is unavailable", ) @@ -55,20 +65,23 @@ async def get_status( # Get PowerMem config powermem_config = auto_config() - storage_type = None + storage_type = getattr(request.app.state, "storage_type", None) llm_provider = None + storage_capabilities = getattr(request.app.state, "storage_capabilities", None) + memory_service_ready = bool(getattr(request.app.state, "service_ready", False)) + startup_error = getattr(request.app.state, "service_startup_error", None) if isinstance(powermem_config, dict): # Extract from dict config vector_store = powermem_config.get("vector_store") or powermem_config.get("database", {}) - storage_type = vector_store.get("provider") if isinstance(vector_store, dict) else None + storage_type = storage_type or (vector_store.get("provider") if isinstance(vector_store, dict) else None) llm = powermem_config.get("llm", {}) llm_provider = llm.get("provider") if isinstance(llm, dict) else None else: # Extract from config object if hasattr(powermem_config, "vector_store") and powermem_config.vector_store: - storage_type = powermem_config.vector_store.provider + storage_type = storage_type or powermem_config.vector_store.provider if hasattr(powermem_config, "llm") and powermem_config.llm: llm_provider = powermem_config.llm.provider @@ -78,6 +91,17 @@ async def get_status( # Check dependencies dependencies = await check_all_dependencies() + dependencies["memory_service"] = DependencyStatus( + name="memory_service", + status="healthy" if memory_service_ready else "unavailable", + error_message=(str(startup_error)[:200] if startup_error else None), + last_checked=datetime.utcnow(), + ) + + if storage_capabilities is None: + from powermem.platform_defaults import storage_capabilities as build_storage_capabilities + + storage_capabilities = build_storage_capabilities(storage_type or "") # Determine overall system status based on dependencies system_status = "operational" @@ -100,6 +124,9 @@ async def get_status( version=powermem_version, storage_type=storage_type, llm_provider=llm_provider, + memory_service_ready=memory_service_ready, + startup_error=(str(startup_error)[:200] if startup_error else None), + storage_capabilities=storage_capabilities, uptime_seconds=uptime_seconds, started_at=SERVER_START_TIME, dependencies=dependencies_dict, @@ -120,6 +147,9 @@ async def get_status( version=powermem_version, storage_type=None, llm_provider=None, + memory_service_ready=bool(getattr(request.app.state, "service_ready", False)), + startup_error=str(getattr(request.app.state, "service_startup_error", "") or e)[:200], + storage_capabilities=getattr(request.app.state, "storage_capabilities", None), uptime_seconds=uptime_seconds, started_at=SERVER_START_TIME, dependencies={}, diff --git a/src/server/api/v1/users.py b/src/server/api/v1/users.py index 9f44180a9..232d9ff03 100644 --- a/src/server/api/v1/users.py +++ b/src/server/api/v1/users.py @@ -12,6 +12,7 @@ from ...middleware.auth import verify_api_key from ...middleware.rate_limit import limiter, get_rate_limit_string from ...utils.converters import user_profile_to_response, memory_dict_to_response +from ...utils.service_errors import service_unavailable_message router = APIRouter(prefix="/users", tags=["users"]) @@ -23,7 +24,7 @@ def get_user_service(request: Request) -> UserService: from ...models.errors import ErrorCode, APIError raise APIError( code=ErrorCode.INTERNAL_ERROR, - message="User service unavailable: storage backend initialization failed", + message=service_unavailable_message(request, "User"), status_code=503, ) return service diff --git a/src/server/cli/server.py b/src/server/cli/server.py index 1c99e3220..69bca4436 100644 --- a/src/server/cli/server.py +++ b/src/server/cli/server.py @@ -192,7 +192,8 @@ def _assert_bind_available(host: str, port: int) -> None: try: # Match uvicorn/asyncio TCP bind behavior so restart probes do not # reject ports that are only held by reusable TCP shutdown state. - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if not _sys.platform.startswith("win"): + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) probe.bind(sockaddr) except OSError as exc: if _is_address_in_use(exc): diff --git a/src/server/main.py b/src/server/main.py index 02442f903..ba47cda50 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -58,18 +58,40 @@ async def _service_lifespan(app: FastAPI): from .services.agent_service import AgentService logger.info("Initializing service singletons...") + app.state.memory_service = None + app.state.search_service = None + app.state.user_service = None + app.state.agent_service = None + app.state.service_ready = False + app.state.service_startup_error = None + app.state.storage_type = None + app.state.storage_capabilities = None try: + from powermem.platform_defaults import ( + database_provider_explicitly_configured, + sqlite_capability_warning, + storage_capabilities, + ) + app.state.memory_service = MemoryService() app.state.search_service = SearchService() app.state.user_service = UserService() app.state.agent_service = AgentService() + storage_type = getattr(app.state.memory_service.memory, "storage_type", None) + app.state.storage_type = storage_type + defaulted = not database_provider_explicitly_configured() + app.state.storage_capabilities = storage_capabilities( + storage_type or "", + defaulted=defaulted, + ) + warning = sqlite_capability_warning(storage_type or "", defaulted=defaulted) + if warning: + logger.warning("PowerMem is running with SQLite storage. %s", warning) + app.state.service_ready = True logger.info("Service singletons initialized") except Exception as e: logger.error(f"Failed to initialize service singletons: {e}", exc_info=True) - app.state.memory_service = None - app.state.search_service = None - app.state.user_service = None - app.state.agent_service = None + app.state.service_startup_error = str(e) yield diff --git a/src/server/models/response.py b/src/server/models/response.py index 08d66a813..90191fc3c 100644 --- a/src/server/models/response.py +++ b/src/server/models/response.py @@ -280,6 +280,8 @@ class HealthResponse(BaseModel): """Response model for health check""" status: str = Field(..., description="Health status") + memory_service_ready: Optional[bool] = Field(None, description="Whether the memory service initialized successfully") + storage_type: Optional[str] = Field(None, description="Storage backend type") timestamp: datetime = Field(default_factory=get_current_datetime, description="Check timestamp") @field_serializer('timestamp') @@ -319,6 +321,16 @@ def serialize_datetime(self, value: datetime, _info): return utc_value.replace(tzinfo=None).isoformat() + "Z" +class StorageCapabilities(BaseModel): + """Storage capability summary for the active backend.""" + + provider: Optional[str] = Field(None, description="Storage provider") + defaulted: bool = Field(False, description="Whether provider was selected by default") + full_stack_available: bool = Field(False, description="Whether the full PowerMem feature stack is available") + limitations: List[str] = Field(default_factory=list, description="Known limitations for the active backend") + recommendation: Optional[str] = Field(None, description="Recommendation to unlock full capabilities") + + class StatusResponse(BaseModel): """Response model for system status""" @@ -326,6 +338,9 @@ class StatusResponse(BaseModel): version: str = Field(..., description="API version") storage_type: Optional[str] = Field(None, description="Storage backend type") llm_provider: Optional[str] = Field(None, description="LLM provider") + memory_service_ready: Optional[bool] = Field(None, description="Whether the memory service initialized successfully") + startup_error: Optional[str] = Field(None, description="Startup error if service initialization failed") + storage_capabilities: Optional[StorageCapabilities] = Field(None, description="Storage backend capability summary") uptime_seconds: Optional[float] = Field(None, description="Service uptime in seconds") started_at: Optional[datetime] = Field(None, description="Service start time") dependencies: Optional[Dict[str, Dict[str, Any]]] = Field(None, description="Dependency health status") diff --git a/src/server/utils/service_errors.py b/src/server/utils/service_errors.py new file mode 100644 index 000000000..1ee19d555 --- /dev/null +++ b/src/server/utils/service_errors.py @@ -0,0 +1,18 @@ +"""Helpers for service readiness errors.""" + +from __future__ import annotations + +from fastapi import Request + + +def service_unavailable_message(request: Request, service_name: str) -> str: + message = f"{service_name} service unavailable: storage backend initialization failed" + startup_error = getattr(request.app.state, "service_startup_error", None) + if startup_error: + message += f" ({str(startup_error)[:200]})" + message += ( + ". For local basic storage, set DATABASE_PROVIDER=sqlite. " + "For the full PowerMem capability stack, use embedded SeekDB on Linux " + 'with "powermem[seekdb]" or configure remote OceanBase with OCEANBASE_HOST.' + ) + return message diff --git a/tests/unit/server/test_server_cli.py b/tests/unit/server/test_server_cli.py index 1f97a9bad..9914a4959 100644 --- a/tests/unit/server/test_server_cli.py +++ b/tests/unit/server/test_server_cli.py @@ -244,11 +244,14 @@ def close(self): server_cli._assert_bind_available("0.0.0.0", 8848) - assert calls == [ - ("setsockopt", socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), + expected_calls = [] + if not sys.platform.startswith("win"): + expected_calls.append(("setsockopt", socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)) + expected_calls.extend([ ("bind", ("0.0.0.0", 8848)), ("close",), - ] + ]) + assert calls == expected_calls def test_cli_starts_one_browser_waiter_with_reload_and_workers(monkeypatch): diff --git a/tests/unit/server/test_system_health.py b/tests/unit/server/test_system_health.py new file mode 100644 index 000000000..59753564e --- /dev/null +++ b/tests/unit/server/test_system_health.py @@ -0,0 +1,34 @@ +import json + +import pytest + + +def test_public_health_does_not_expose_startup_error(): + pytest.importorskip("fastapi", exc_type=ImportError) + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from server.api.v1.system import router + + app = FastAPI() + app.state.service_ready = False + app.state.storage_type = "sqlite" + app.state.service_startup_error = ( + "failed to connect to postgresql://user:secret@db.internal/powermem " + "from C:/sensitive/path" + ) + app.include_router(router, prefix="/api/v1") + + response = TestClient(app).get("/api/v1/system/health") + + assert response.status_code == 200 + body = response.json() + assert body["data"]["status"] == "degraded" + assert body["data"]["memory_service_ready"] is False + assert "startup_error" not in body["data"] + + response_text = json.dumps(body) + assert "secret" not in response_text + assert "db.internal" not in response_text + assert "sensitive/path" not in response_text diff --git a/tests/unit/test_seekdb_default_storage.py b/tests/unit/test_seekdb_default_storage.py index 9cf4105ea..86b1df270 100644 --- a/tests/unit/test_seekdb_default_storage.py +++ b/tests/unit/test_seekdb_default_storage.py @@ -10,13 +10,23 @@ from __future__ import annotations -def test_memory_config_default_storage_is_oceanbase_in_embedded_mode(monkeypatch): - """`MemoryConfig()` with no env vars defaults to the OceanBase provider - with an empty host — i.e. embedded seekdb on disk. - """ +def test_memory_config_default_storage_is_oceanbase_when_embedded_seekdb_available(monkeypatch): + """Linux with embedded SeekDB capability keeps the OceanBase embedded default.""" monkeypatch.delenv("DATABASE_PROVIDER", raising=False) monkeypatch.delenv("OCEANBASE_HOST", raising=False) + import powermem.platform_defaults as platform_defaults + + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + monkeypatch.setattr( + platform_defaults.importlib.util, + "find_spec", + lambda name: object() + if name in {"pyobvector", "pyseekdb", "pylibseekdb"} + else None, + ) + from powermem.configs import MemoryConfig from powermem.storage.config.oceanbase import OceanBaseConfig @@ -27,12 +37,19 @@ def test_memory_config_default_storage_is_oceanbase_in_embedded_mode(monkeypatch assert cfg.vector_store.ob_path == "./seekdb_data" -def test_database_settings_default_provider_is_oceanbase(monkeypatch): +def test_database_settings_default_provider_matches_platform_helper(monkeypatch): monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + import powermem.platform_defaults as platform_defaults from powermem.config_loader import DatabaseSettings - assert DatabaseSettings().provider == "oceanbase" + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) + model_config = dict(DatabaseSettings.model_config) + model_config["env_file"] = None + monkeypatch.setattr(DatabaseSettings, "model_config", model_config) + + assert DatabaseSettings().provider == platform_defaults.default_database_provider() def test_oceanbase_provider_picks_up_remote_host(monkeypatch): diff --git a/tests/unit/test_storage_default_provider.py b/tests/unit/test_storage_default_provider.py new file mode 100644 index 000000000..f1da92c16 --- /dev/null +++ b/tests/unit/test_storage_default_provider.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import powermem.platform_defaults as platform_defaults + + +def _ignore_env_files(monkeypatch): + monkeypatch.setattr(platform_defaults, "_configured_env_files", lambda: []) + + +def _mock_find_spec(monkeypatch, available: set[str]): + def fake_find_spec(name): + return object() if name in available else None + + monkeypatch.setattr(platform_defaults.importlib.util, "find_spec", fake_find_spec) + + +def test_windows_zero_config_defaults_to_sqlite(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "sqlite" + assert decision.defaulted is True + assert decision.embedded_seekdb_available is False + + +def test_linux_with_seekdb_defaults_to_oceanbase(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + assert decision.embedded_seekdb_available is True + + +def test_linux_without_seekdb_defaults_to_sqlite(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb"}) + + assert platform_defaults.default_database_provider() == "sqlite" + + +def test_modules_with_missing_spec_are_treated_as_unavailable(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + + def bad_find_spec(name): + if name == "pyobvector": + raise ValueError("pyobvector.__spec__ is not set") + return object() + + monkeypatch.setattr(platform_defaults.importlib.util, "find_spec", bad_find_spec) + + assert platform_defaults.embedded_seekdb_available() is False + assert platform_defaults.default_database_provider() == "sqlite" + assert "pyobvector=False" in platform_defaults.embedded_seekdb_unavailable_message() + + +def test_explicit_database_provider_wins(monkeypatch): + monkeypatch.setenv("DATABASE_PROVIDER", "postgres") + monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "linux") + _mock_find_spec(monkeypatch, {"pyobvector", "pyseekdb", "pylibseekdb"}) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "postgres" + assert decision.defaulted is False + + +def test_oceanbase_host_selects_remote_oceanbase(monkeypatch): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.setenv("OCEANBASE_HOST", "ob.example.com") + _ignore_env_files(monkeypatch) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + + +def test_env_file_database_provider_matches_database_settings(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=oceanbase\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + from powermem.config_loader import DatabaseSettings + + model_config = dict(DatabaseSettings.model_config) + model_config["env_file"] = str(env_file) + monkeypatch.setattr(DatabaseSettings, "model_config", model_config) + + assert platform_defaults.default_database_provider() == "oceanbase" + assert DatabaseSettings().provider == "oceanbase" + + +def test_environment_database_provider_overrides_env_file(monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_PROVIDER", "sqlite") + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=oceanbase\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "sqlite" + assert decision.defaulted is False + + +def test_env_file_oceanbase_host_selects_remote_oceanbase(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + monkeypatch.setattr(platform_defaults.sys, "platform", "win32") + _mock_find_spec(monkeypatch, set()) + + env_file = tmp_path / ".env" + env_file.write_text("OCEANBASE_HOST=ob.example.com\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + decision = platform_defaults.choose_default_database_provider() + + assert decision.provider == "oceanbase" + assert decision.defaulted is True + + +def test_storage_capabilities_treats_env_file_provider_as_explicit(monkeypatch, tmp_path): + monkeypatch.delenv("DATABASE_PROVIDER", raising=False) + monkeypatch.delenv("OCEANBASE_HOST", raising=False) + + env_file = tmp_path / ".env" + env_file.write_text("DATABASE_PROVIDER=sqlite\n", encoding="utf-8") + monkeypatch.setattr( + platform_defaults, + "_configured_env_files", + lambda: [str(env_file)], + ) + + capabilities = platform_defaults.storage_capabilities("sqlite") + + assert capabilities["defaulted"] is False + + +def test_sqlite_capabilities_include_full_stack_warning(): + capabilities = platform_defaults.storage_capabilities("sqlite", defaulted=True) + + assert capabilities["full_stack_available"] is False + assert "Graph Store is not available" in capabilities["limitations"] + assert "OceanBase" in capabilities["recommendation"]