Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION=.powercontext/vec1.so
# POWERCONTEXT_SERVER_DATABASE_KIND=oceanbase
# POWERCONTEXT_SERVER_DATABASE_URL=mysql+aoceanbase://user:password@host:2881/powercontext?charset=utf8mb4

# To use embedded seekDB instead, install powercontext[server,seekdb] and replace the SQLite database values.
Comment thread
Teingi marked this conversation as resolved.
# Omit DATABASE_PATH to use the Server data directory's seekdb subdirectory ($POWERCONTEXT_HOME/seekdb when set).
# POWERCONTEXT_SERVER_DATABASE_KIND=seekdb
# POWERCONTEXT_SERVER_DATABASE_PATH=.powercontext/seekdb

# Source windows are flushed explicitly unless the optional interval is set.
POWERCONTEXT_SERVER_RUNTIME_SOURCE_WINDOW_LIMIT=100
# POWERCONTEXT_SERVER_RUNTIME_SCHEDULE_SECONDS=60
Expand Down
33 changes: 33 additions & 0 deletions docs/en/docs/how-to/install-and-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@ disable the Dashboard explicitly.

`Ctrl-C` performs a clean shutdown. Restarting the command reopens the same database.

## Use embedded seekDB

Embedded seekDB is available on Linux and macOS when a compatible `pylibseekdb` wheel is available. Windows does not
support this embedded backend. Install or replace the tool with the optional seekDB extra:

```bash
uv tool install --force "powercontext[cli,server,seekdb] @ git+https://github.com/oceanbase/powercontext.git@master"
```

When switching from SQLite, remove `POWERCONTEXT_SERVER_DATABASE_URL` and
`POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION` from `.env`, or unset them in the shell. Those settings are not valid
for seekDB. Then select the backend and start the Server:

```bash
unset POWERCONTEXT_SERVER_DATABASE_URL
unset POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION
export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb
powercontext server run
```

PowerContext always uses seekDB's built-in `test` database. Leave `POWERCONTEXT_SERVER_DATABASE_PATH` unset to store
the instance in the `seekdb` subdirectory of the PowerContext user data directory. If `POWERCONTEXT_HOME` is set, the
default is `$POWERCONTEXT_HOME/seekdb`; set `POWERCONTEXT_SERVER_DATABASE_PATH` only when a different location is
required.

In another terminal, verify that the Server and database are ready:

```bash
powercontext doctor
powercontext ready
powercontext capabilities
```

## Verify the installation

```bash
Expand Down
32 changes: 32 additions & 0 deletions docs/zh/docs/how-to/install-and-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,38 @@ powercontext server run

按 `Ctrl-C` 可正常关闭。再次运行该命令会打开同一个数据库。

## 使用嵌入式 seekDB

在有兼容 `pylibseekdb` wheel 的 Linux 和 macOS 系统上可以使用嵌入式 seekDB;Windows 不支持该嵌入式
后端。安装或替换工具时加入可选的 seekDB extra:

```bash
uv tool install --force "powercontext[cli,server,seekdb] @ git+https://github.com/oceanbase/powercontext.git@master"
```

从 SQLite 切换时,需要从 `.env` 中删除 `POWERCONTEXT_SERVER_DATABASE_URL` 和
`POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION`,或在 shell 中取消这两个变量;seekDB 不接受这些配置。
然后选择 seekDB 后端并启动 Server:

```bash
unset POWERCONTEXT_SERVER_DATABASE_URL
unset POWERCONTEXT_SERVER_DATABASE_VEC1_EXTENSION
export POWERCONTEXT_SERVER_DATABASE_KIND=seekdb
powercontext server run
```

PowerContext 固定使用 seekDB 内置的 `test` 数据库。未设置 `POWERCONTEXT_SERVER_DATABASE_PATH` 时,实例保存在
PowerContext 用户数据目录的 `seekdb` 子目录中;如果设置了 `POWERCONTEXT_HOME`,默认路径为
`$POWERCONTEXT_HOME/seekdb`。只有需要其他位置时才设置 `POWERCONTEXT_SERVER_DATABASE_PATH`。

在另一个终端确认 Server 和数据库已经就绪:

```bash
powercontext doctor
powercontext ready
powercontext capabilities
```

## 验证安装

```bash
Expand Down
9 changes: 8 additions & 1 deletion e2e/bub/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ builtin = [
"pyobvector>=0.2.28,<0.3",
"sqlalchemy[asyncio]>=2,<3",
]
seekdb = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extra also changes the root package metadata recorded in e2e/bub/uv.lock, but that lockfile was not regenerated. On this head, uvx --from uv==0.10.12 uv lock --project e2e/bub --locked exits with The lockfile needs to be updated; the same command passes on the base commit. Please regenerate and commit e2e/bub/uv.lock. It may also be worth making the E2E validation run the locked check, since the current sync step can update the file silently.

"powercontext[builtin]",
"pylibseekdb>=1.3.0.post4,<2; sys_platform == 'linux' or sys_platform == 'darwin'",
]
client = [
"httpx[socks]>=0.28,<1",
"opentelemetry-api>=1.30,<2",
Expand Down
27 changes: 27 additions & 0 deletions src/powercontext/builtin/persistence/seekdb/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright (c) 2026 OceanBase.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Embedded seekDB async relational profile."""

from powercontext.builtin.persistence.seekdb.profile import (
SeekDBConfig,
SeekDBProfile,
SeekDBUnavailableError,
)

__all__ = (
"SeekDBConfig",
"SeekDBProfile",
"SeekDBUnavailableError",
)
175 changes: 175 additions & 0 deletions src/powercontext/builtin/persistence/seekdb/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Copyright (c) 2026 OceanBase.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Embedded seekDB profile using its local runtime and async MySQL socket."""

from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager, suppress
from importlib import import_module
from pathlib import Path
from types import ModuleType
from typing import Any, Literal, Protocol, cast

from pydantic import BaseModel, ConfigDict, field_validator
from pyobvector import AsyncOceanBaseDialect
from sqlalchemy import Table
from sqlalchemy.dialects import registry as dialect_registry
from sqlalchemy.engine import URL
from sqlalchemy.engine.interfaces import DBAPIConnection
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from powercontext.builtin.persistence.database import AsyncDatabase
from powercontext.builtin.persistence.errors import PersistenceError
from powercontext.builtin.persistence.schema import create_tables

_DIALECT_DRIVER = "mysql+aseekdb"
_DIALECT_REGISTRY_NAME = "mysql.aseekdb"


class AsyncSeekDBDialect(AsyncOceanBaseDialect):
"""OceanBase-compatible dialect with seekDB-safe connection shutdown."""

supports_statement_cache = AsyncOceanBaseDialect.supports_statement_cache

def do_close(self, dbapi_connection: DBAPIConnection) -> None:
# seekDB resets the socket while aiomysql drains COM_QUIT. SQLAlchemy's
# terminate path handles that reset and falls back to closing the transport.
self.do_terminate(dbapi_connection)


class _SeekDBInstance(Protocol):
def connection_options(self) -> Mapping[str, object]: ...

def close(self) -> None: ...


class SeekDBUnavailableError(PersistenceError):
"""Raised when the embedded seekDB binding is unavailable."""

def __init__(self) -> None:
super().__init__("Embedded seekDB requires powercontext[seekdb] on a supported Linux or macOS platform")


class SeekDBConfig(BaseModel):
"""Validated configuration for one embedded seekDB instance."""

model_config = ConfigDict(extra="forbid")

kind: Literal["seekdb"] = "seekdb"
path: Path
database: Literal["test"] = "test"
echo: bool = False
pool_pre_ping: bool = True

@field_validator("path", mode="before")
@classmethod
def require_path(cls, value: object) -> object:
if isinstance(value, str) and not value.strip():
raise ValueError("seekDB path must not be empty") # noqa: TRY003
return value


class SeekDBProfile:
"""An initialized embedded seekDB profile with explicit runtime ownership."""

def __init__(self, *, database: AsyncDatabase, tables: tuple[Table, ...]) -> None:
self.database = database
self.tables = tables

@classmethod
@asynccontextmanager
async def open(
cls,
config: SeekDBConfig,
*,
tables: tuple[Table, ...],
) -> AsyncIterator[SeekDBProfile]:
"""Start seekDB locally and connect through its async Unix socket."""

path = config.path.expanduser().resolve()
path.parent.mkdir(parents=True, exist_ok=True)
module = _load_binding()
instance = await _open_instance(module, path)
try:
engine = _create_engine(config, instance.connection_options())
database = AsyncDatabase.own(engine)
profile = cls(database=database, tables=tables)
try:
async with database.transaction() as connection:
await create_tables(connection, tables)
yield profile
finally:
close_task = asyncio.create_task(database.close())
try:
await asyncio.shield(close_task)
except asyncio.CancelledError:
await close_task

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep the cleanup task shielded after the first cancellation? The initial asyncio.shield(close_task) protects database.close(), but the except branch awaits close_task directly. A second cancellation therefore cancels the close, and the outer finally still calls instance.close() while an active transaction is running.

I reproduced this on the current head with pylibseekdb==1.3.0.post4: hold an insert transaction, start __aexit__, then cancel the shutdown task twice. The transaction fails with OperationalError 2013 (Connection reset by peer), and the inserted row is missing after reopening the database. With one cancellation, the row is preserved.

Please wait for the cleanup task through repeated shielded awaits, defer and re-raise cancellation only after cleanup completes, and add a regression test covering multiple cancellations. _open_instance() has the same unshielded follow-up await.

raise
finally:
instance.close()


def _load_binding() -> ModuleType:
try:
return import_module("pylibseekdb")
except ModuleNotFoundError as error:
if error.name != "pylibseekdb":
raise
raise SeekDBUnavailableError from None


async def _open_instance(module: ModuleType, path: Path) -> _SeekDBInstance:
open_task = asyncio.create_task(cast(Any, module).aopen(str(path)))
try:
return cast(_SeekDBInstance, await asyncio.shield(open_task))
except asyncio.CancelledError:
with suppress(BaseException):
instance = cast(_SeekDBInstance, await open_task)
instance.close()
raise


def _create_engine(config: SeekDBConfig, connection_options: Mapping[str, object]) -> AsyncEngine:
options = dict(connection_options)
username = str(options.pop("user", "root"))
password_value = options.pop("password", None)
host = str(options.pop("host", "localhost"))
port_value = options.pop("port", None)
# seekDB's handshake currently omits the autocommit status flag, so
# aiomysql otherwise mistakes the default-on session for an explicit
# transaction and rollback becomes ineffective.
options["init_command"] = "SET autocommit = 0"
url = URL.create(
_DIALECT_DRIVER,
username=username,
password=None if password_value is None else str(password_value),
host=host,
port=None if port_value is None else int(cast(int | str, port_value)),
database=config.database,
Comment thread
Teingi marked this conversation as resolved.
query={"charset": "utf8mb4"},
)
_register_seekdb_dialect()
return create_async_engine(
url,
connect_args=options,
echo=config.echo,
pool_pre_ping=config.pool_pre_ping,
)


def _register_seekdb_dialect() -> None:
dialect_registry.register(_DIALECT_REGISTRY_NAME, __name__, "AsyncSeekDBDialect")
16 changes: 9 additions & 7 deletions src/powercontext/builtin/runtime/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
OceanBaseMemoryVectorIndex,
)
from powercontext.builtin.persistence.oceanbase.profile import OceanBaseConfig, OceanBaseProfile
from powercontext.builtin.persistence.seekdb.profile import SeekDBConfig, SeekDBProfile
from powercontext.builtin.persistence.sqlite.experience_index import SQLiteExperienceFTSIndex
from powercontext.builtin.persistence.sqlite.memory_index import SQLiteMemoryFTSIndex, SQLiteMemoryVec1Index
from powercontext.builtin.persistence.sqlite.profile import SQLiteConfig, SQLiteProfile
Expand Down Expand Up @@ -345,18 +346,19 @@ async def open_builtin_contexts(
memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit,
)
return
if not isinstance(database, OceanBaseConfig):
raise BuiltinConfigurationError("database")

experience_index = OceanBaseExperienceFTSIndex()
indexes = [OceanBaseMemoryFTSIndex()]
if embedding_model is not None:
indexes.append(OceanBaseMemoryVectorIndex(embedding_model.profile))
index = CompositeMemoryIndex(*indexes)
async with OceanBaseProfile.open(
database,
tables=BUILTIN_TABLES + report_tables + index.tables,
) as profile:
tables = BUILTIN_TABLES + report_tables + index.tables
if isinstance(database, OceanBaseConfig):
profile_context = OceanBaseProfile.open(database, tables=tables)
elif isinstance(database, SeekDBConfig):
profile_context = SeekDBProfile.open(database, tables=tables)
else:
raise BuiltinConfigurationError("database")
async with profile_context as profile:
async with profile.database.transaction() as connection:
await index.initialize(connection)
await experience_index.initialize(connection)
Expand Down
3 changes: 2 additions & 1 deletion src/powercontext/builtin/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from powercontext.builtin.artifacts.memory.prompts import MemoryExtractionProfile
from powercontext.builtin.artifacts.skill import CodexSkillRoot
from powercontext.builtin.persistence.oceanbase import OceanBaseConfig
from powercontext.builtin.persistence.seekdb import SeekDBConfig
from powercontext.builtin.persistence.sqlite import SQLiteConfig


Expand Down Expand Up @@ -109,7 +110,7 @@ def require_host_for_roots(self) -> ExternalSkillsConfig:
return self


DatabaseConfig = SQLiteConfig | OceanBaseConfig
DatabaseConfig = SQLiteConfig | OceanBaseConfig | SeekDBConfig


def normalize_database_discriminator(value: Any) -> Any:
Expand Down
Loading
Loading