From 9663ee3dbcc4d69931b501a6f64040f66366afae Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Fri, 10 Apr 2026 19:20:55 +0800
Subject: [PATCH 01/16] Update references from CoPaw to QwenPaw in README
---
README.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 781f5412..f2f413c0 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
| Date | Title |
|------------|-----------------------------------------------------------------|
-| 2026-03-30 | [CoPaw Context Management Design](docs/copaw_context_design.md) |
+| 2026-03-30 | [Context Management Design](docs/copaw_context_design.md) |
---
@@ -53,7 +53,7 @@ ReMe achieves state-of-the-art results on the LoCoMo and HaluMem benchmarks; see
-- **Personal assistant**: Provide long-term memory for agents like [CoPaw](https://github.com/agentscope-ai/CoPaw),
+- **Personal assistant**: Provide long-term memory for agents like [QwenPaw](https://github.com/agentscope-ai/CoPaw),
remembering user preferences and conversation history.
- **Coding assistant**: Record code style preferences and project context, maintaining a consistent development
experience across sessions.
@@ -73,7 +73,7 @@ ReMe achieves state-of-the-art results on the LoCoMo and HaluMem benchmarks; see
> Memory as files, files as memory.
Treat **memory as files** — readable, editable, and copyable.
-[CoPaw](https://github.com/agentscope-ai/CoPaw) integrates long-term memory and context management by inheriting from
+[QwenPaw](https://github.com/agentscope-ai/CoPaw) integrates long-term memory and context management by inheriting from
`ReMeLight`.
| Traditional memory system | File-based ReMe |
@@ -245,7 +245,7 @@ flowchart TD
---
-[CoPaw MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/reme_light_memory_manager.py)
+[MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/reme_light_memory_manager.py)
inherits `ReMeLight` and integrates its memory capabilities into the agent reasoning loop:
```mermaid
@@ -671,7 +671,7 @@ For more details on how to reproduce the experiments, see [quickstart.md](benchm
- **Need a new feature?** Open a feature request; we’ll evolve ReMe together with the community.
- **Code contributions**: All forms of contributions are welcome. Please see
the [contribution guide](docs/contribution.md).
-- **Acknowledgements**: We thank excellent open-source projects such as OpenClaw, Mem0, MemU, and CoPaw for their
+- **Acknowledgements**: We thank excellent open-source projects such as OpenClaw, Mem0, MemU, and QwenPaw for their
inspiration and support.
### Contributors
From 625d184ca12cb4bc2be69618b37b817b50febd07 Mon Sep 17 00:00:00 2001
From: Zhouwk <57825291+nitwtog@users.noreply.github.com>
Date: Mon, 13 Apr 2026 10:49:26 +0800
Subject: [PATCH 02/16] =?UTF-8?q?=E6=B7=BB=E5=8A=A0log=5Fto=5Ffile?=
=?UTF-8?q?=E7=9A=84=E5=BC=80=E5=85=B3=20(#205)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
benchmark/halumem/quickstart.md | 19 ++++++++++--
reme/core/application.py | 7 ++++-
reme/core/schema/service_config.py | 1 +
reme/core/service_context.py | 2 ++
reme/core/utils/logger_utils.py | 49 +++++++++++++++++-------------
reme/reme.py | 2 ++
6 files changed, 55 insertions(+), 25 deletions(-)
diff --git a/benchmark/halumem/quickstart.md b/benchmark/halumem/quickstart.md
index 59dc68fa..4ef30846 100644
--- a/benchmark/halumem/quickstart.md
+++ b/benchmark/halumem/quickstart.md
@@ -14,17 +14,30 @@ conda activate ./reme-env
pip install .
```
-### 2. Clone the Repository
+### 2. Download the Dataset
```bash
cd ./benchmark/halumem
-git clone https://github.com/MemTensor/HaluMem.git
+mkdir -p data
+curl -L "https://huggingface.co/datasets/IAAR-Shanghai/HaluMem/resolve/main/HaluMem-Medium.jsonl?download=true" -o data/HaluMem-Medium.jsonl
+curl -L "https://huggingface.co/datasets/IAAR-Shanghai/HaluMem/resolve/main/HaluMem-Long.jsonl?download=true" -o data/HaluMem-Long.jsonl
+```
+
+Dataset page:
+https://huggingface.co/datasets/IAAR-Shanghai/HaluMem/tree/main
+
+If the official source is slow or inaccessible in mainland China, you can use a mirror:
+```bash
+cd ./benchmark/halumem
+mkdir -p data
+curl -L "https://hf-mirror.com/datasets/IAAR-Shanghai/HaluMem/resolve/main/HaluMem-Medium.jsonl?download=true" -o data/HaluMem-Medium.jsonl
+curl -L "https://hf-mirror.com/datasets/IAAR-Shanghai/HaluMem/resolve/main/HaluMem-Long.jsonl?download=true" -o data/HaluMem-Long.jsonl
```
### 3. Run Experiments
Launch the ReMe service to enable memory library functionality:
```bash
clear && python benchmark/halumem/eval_reme.py \
- --data_path benchmark/halumem/HaluMem/data/HaluMem-Medium.jsonl \
+ --data_path benchmark/halumem/data/HaluMem-Medium.jsonl \
--reme_model_name gpt-4o-mini-2024-07-18 \
--eval_model_name gpt-4o-mini-2024-07-18 \
--batch_size 40 \
diff --git a/reme/core/application.py b/reme/core/application.py
index 8b5b1932..042b2e80 100644
--- a/reme/core/application.py
+++ b/reme/core/application.py
@@ -44,6 +44,7 @@ class Application:
config_path: str | None = None,
enable_logo: bool = True,
log_to_console: bool = True,
+ log_to_file: bool = True,
enable_load_env: bool = True,
parser: type[PydanticConfigParser] | None = None,
default_as_llm_config: dict | None = None,
@@ -73,6 +74,7 @@ class Application:
config_path=config_path,
enable_logo=enable_logo,
log_to_console=log_to_console,
+ log_to_file=log_to_file,
default_as_llm_config=default_as_llm_config,
default_as_llm_formatter_config=default_as_llm_formatter_config,
default_llm_config=default_llm_config,
@@ -144,7 +146,10 @@ class Application:
logger.warning("Application has already started.")
return self
- init_logger(log_to_console=self.service_config.log_to_console)
+ init_logger(
+ log_to_console=self.service_config.log_to_console,
+ log_to_file=self.service_config.log_to_file,
+ )
logger.info(f"Init ReMe with config: {self.service_config.model_dump_json()}")
working_path = Path(self.service_config.working_dir)
diff --git a/reme/core/schema/service_config.py b/reme/core/schema/service_config.py
index 48b97e0a..1f8a6816 100644
--- a/reme/core/schema/service_config.py
+++ b/reme/core/schema/service_config.py
@@ -122,6 +122,7 @@ class ServiceConfig(BasicConfig):
)
ray_max_workers: int = Field(default=-1)
log_to_console: bool = Field(default=True)
+ log_to_file: bool = Field(default=True)
disabled_flows: list[str] = Field(default_factory=list)
enabled_flows: list[str] = Field(default_factory=list)
diff --git a/reme/core/service_context.py b/reme/core/service_context.py
index 2c58fe7d..5e57c9e4 100644
--- a/reme/core/service_context.py
+++ b/reme/core/service_context.py
@@ -34,6 +34,7 @@ class ServiceContext(BaseDict):
config_path: str | None = None,
enable_logo: bool = True,
log_to_console: bool = True,
+ log_to_file: bool = True,
default_as_llm_config: dict | None = None,
default_as_llm_formatter_config: dict | None = None,
default_as_token_counter_config: dict | None = None,
@@ -79,6 +80,7 @@ class ServiceContext(BaseDict):
{
"enable_logo": enable_logo,
"log_to_console": log_to_console,
+ "log_to_file": log_to_file,
"working_dir": working_dir,
},
)
diff --git a/reme/core/utils/logger_utils.py b/reme/core/utils/logger_utils.py
index 512c0a42..cc141724 100644
--- a/reme/core/utils/logger_utils.py
+++ b/reme/core/utils/logger_utils.py
@@ -5,13 +5,19 @@ import sys
from datetime import datetime
-def init_logger(log_dir: str = "logs", level: str = "INFO", log_to_console: bool = True) -> None:
+def init_logger(
+ log_dir: str = "logs",
+ level: str = "INFO",
+ log_to_console: bool = True,
+ log_to_file: bool = True,
+) -> None:
"""Initialize the logger with both file and console handlers.
Args:
log_dir: Directory path for log files
level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_to_console: Whether to print logs to console/screen
+ log_to_file: Whether to persist logs to files under log_dir
"""
from loguru import logger
@@ -28,25 +34,26 @@ def init_logger(log_dir: str = "logs", level: str = "INFO", log_to_console: bool
)
# Try to configure file-based logging (skip if permission denied)
- try:
- # Ensure the logging directory exists
- os.makedirs(log_dir, exist_ok=True)
+ if log_to_file:
+ try:
+ # Ensure the logging directory exists
+ os.makedirs(log_dir, exist_ok=True)
- # Generate filename based on the current timestamp
- # Use dashes instead of colons for Windows compatibility
- current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- log_filename = f"{current_ts}.log"
- log_filepath = os.path.join(log_dir, log_filename)
+ # Generate filename based on the current timestamp
+ # Use dashes instead of colons for Windows compatibility
+ current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ log_filename = f"{current_ts}.log"
+ log_filepath = os.path.join(log_dir, log_filename)
- # Configure file-based logging with rotation and compression
- logger.add(
- log_filepath,
- level=level,
- rotation="00:00",
- retention="7 days",
- compression="zip",
- encoding="utf-8",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}",
- )
- except Exception as e:
- logger.error(f"Error configuring file logging: {e}")
+ # Configure file-based logging with rotation and compression
+ logger.add(
+ log_filepath,
+ level=level,
+ rotation="00:00",
+ retention="7 days",
+ compression="zip",
+ encoding="utf-8",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}",
+ )
+ except Exception as e:
+ logger.error(f"Error configuring file logging: {e}")
diff --git a/reme/reme.py b/reme/reme.py
index 5e2aa5b2..f559ad71 100644
--- a/reme/reme.py
+++ b/reme/reme.py
@@ -46,6 +46,7 @@ class ReMe(Application):
config_path: str = "vector",
enable_logo: bool = True,
log_to_console: bool = True,
+ log_to_file: bool = True,
default_llm_config: dict | None = None,
default_embedding_model_config: dict | None = None,
default_vector_store_config: dict | None = None,
@@ -81,6 +82,7 @@ class ReMe(Application):
config_path=config_path,
enable_logo=enable_logo,
log_to_console=log_to_console,
+ log_to_file=log_to_file,
parser=ReMeConfigParser,
default_llm_config=default_llm_config,
default_embedding_model_config=default_embedding_model_config,
From e0d0e3e568e6d2163c068ad05af2cf4536c42ad2 Mon Sep 17 00:00:00 2001
From: Zhouwk <57825291+nitwtog@users.noreply.github.com>
Date: Tue, 28 Apr 2026 15:11:45 +0800
Subject: [PATCH 03/16] =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E5=90=91=E9=87=8F=E6=95=B0=E6=8D=AE=E5=BA=93=E7=9A=84profile?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=20(#221)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(reme): 添加配置选项以启用或禁用个人资料功能
- 在 ReMe 初始化方法中添加 enable_profile 参数,默认值为 True
- 根据 enable_profile 设置决定是否创建 profile 目录和设置 profile_dir
- 在 PersonalSummarizer 中根据 enable_profile 条件性地添加个人资料相关工具
- 在 PersonalRetriever 中根据 enable_profile 条件性地添加 ReadAllProfiles 工具
- 修改 profile_path 属性以在禁用个人资料时返回 None
- 修改 get_profile_handler 方法以在禁用个人资料时返回 None
- 为 enable_profile 参数添加文档说明其用于云向量存储场景
* refactor(benchmark): 重构LongMemEval基准测试中的ReMe实例管理
- 移除未使用的shutil导入
- 将固定的ReMe实例改为每个问题创建独立实例以实现隔离
- 更新LLM配置名称从qwen3-max-think到qwen-max-t
- 修改模型调用逻辑使用正确的model_name参数
- 添加qwen-flash和GPT-4o-mini等新模型配置
- 统一使用"User"作为用户名,通过集合名实现隔离
- 调整并发处理数从4降至1,批处理大小从10增至30
- 每个问题类型采样数从2增至4
- 添加异步上下文管理确保资源正确释放
* reformat 2 files
* refactor(benchmark): 重构长记忆评估中的模型配置
- 将原有的 eval_model_name 替换为专门的 retrieve_model_name 用于检索操作
- 添加对 qwen-max 模型配置的支持
- 更新参数解析器以支持新的检索模型参数
- 修改最大并发数默认值从 1 提升到 4
- 调整样本数量默认值从 4 减少到 1
- 统一模型参数命名规范,区分摘要、检索和评估模型
- 优化内存处理器初始化逻辑,支持独立的检索模型配置
* fix(benchmark): 移除数据路径默认值并设为必填参数
- 将LongMemEval评估脚本中的data_path参数改为必需参数
- 将HaluMem评估脚本中的data_path参数改为必需参数
- 删除了硬编码的默认文件路径配置
- 强制用户显式指定数据集文件路径以避免路径错误
* Update __init__.py
* Update __init__.py
* fix(benchmark): 修复ReMe评估中的模型配置和空值处理问题
- 移除了retrieve_memory调用中不需要的llm_config_name参数
- 修复了长字符串打印的换行格式问题
- 添加了eval_result为空时的初始化处理
- 在accuracy评估中加入了eval_model_name参数传递
* style(benchmark): 格式化模型名称打印输出
- 移除了多行字符串中的换行符和多余空格
- 将模型名称信息合并为单行连续显示
- 保持了原有的打印格式和信息完整性
* docs(readme): 更新文档添加实验结果表格
- 在英文版 README 中添加 🧪 Experiments 章节
- 添加 LoCoMo 和 HaluMem 两个基准测试的结果表格
- 在中文版 README_ZH 中添加 🧪 实验 章节
- 添加 LoCoMo 和 HaluMem 测试集的实验配置说明
- 添加完整的实验数据对比表格和评估协议说明
* docs(readme): 更新文档中的内存系统链接
- 为基于文件的记忆系统添加锚点链接
- 为基于向量库的记忆系统添加锚点链接
- 修复英文文档中的链接格式
- 修复中文文档中的链接格式和空行问题
* docs(readme): update experimental results section in documentation
- Remove outdated experimental data placeholder "Coming soon..."
- Add complete evaluation results for LoCoMo and HaluMem benchmarks
- Include detailed performance metrics tables for all memory methods
- Update experimental settings description with ReMe backbone details
- Align evaluation protocol information with LLM-as-a-Judge approach
- Maintain consistent formatting between English and Chinese documentation
* docs(benchmark): add quick start guides for halumem and longmemeval experiments
- Created HaluMem experiment quick start guide with ReMe integration setup
- Added detailed steps for installing ReMe environment using conda
- Included repository cloning instructions for HaluMem benchmark
- Provided complete command examples for running HaluMem experiments
- Created LongMeMEval quick start guide with data download procedures
- Added wget commands for downloading cleaned dataset files
- Included evaluation script instructions for computing experiment statistics
- Documented parameter configurations for different model types and batch sizes
* docs(longmemeval): update quickstart guide documentation
- Changed project name from Halumem to Longmemeval in title
- Updated description to reference Longmemeval experiments instead of Halumem
- Maintained existing ReMe integration instructions unchanged
* chore(logger): add test comment to logger configuration
- Added test comment in logger utility function
- Removed duplicate log handling by keeping the remove() call
* chore(logger): add test comment to logger configuration
- Added test comment in logger utility function
- Removed duplicate log handling by keeping the remove() call
* feat(core): add file logging capability to application
- Added log_to_file parameter to Application class constructor
- Integrated log_to_file option in logger initialization
- Updated ServiceContext to support file logging configuration
- Modified init_logger function to conditionally enable file logging
- Added log_to_file field to ServiceConfig schema
- Updated ReMe class to include file logging option
- Wrapped file logging setup in conditional check to prevent unnecessary operations
* docs(benchmark): update HaluMem quickstart guide with dataset download instructions
- Replace repository cloning with direct dataset download using curl
- Add commands to download HaluMem-Medium.jsonl and HaluMem-Long.jsonl files
- Include both official Hugging Face and mirror download sources
- Update data path reference from nested directory to local data folder
- Add dataset page link and mirror usage instructions for mainland China access
* feat(memory): add profile retrieval tool and refactor profile management
- Introduce RetrieveProfile tool for fetching specific user profiles
- Refactor ProfileHandler to support both filesystem and vector backends
- Add async methods to ProfileHandler with synchronous fallbacks
- Update PersonalRetriever to support two-stage profile and memory retrieval
- Enhance PersonalSummarizer with improved tool partitioning logic
- Add profile_backend, profile_store_name, and profile_max_capacity configuration options
- Replace direct ProfileHandler imports with get_profile_handler method
- Implement profile search functionality with dedicated prompts and workflows
- Add FileProfileBackend and VectorProfileBackend implementations
- Update base memory tool with new profile configuration parameters
* feat(profile): add custom profile collection name support
- Add profile_collection_name parameter to Application constructor
- Allow custom database collection name for vector profiles instead of default suffix
- Update profile vector store configuration logic to use custom collection name
- Modify _ensure_profile_vector_store_config to handle custom collection names
- Update docstring with detailed parameter descriptions for profile configuration options
* test(history): add single history id acceptance test for multiple mode
- Add test case to verify multiple-mode history lookup accepts a single history_id string
- Create FakeVectorStore stub with minimal implementation for ReadHistory tests
- Return requested history node from vector store mock
- Initialize ReadHistory tool with multiple mode enabled
- Add pylint disable comment for protected access to vector store property
* refactor(memory): update profile handler and vector tools with improved formatting and error handling
- Add module docstring to profiles/__init__.py
- Add pylint disable comments for no-name-in-module and missing-function-docstring
- Format long error message in ProfileHandler.sync_run method for better readability
- Reformat parameters in ProfileHandler.aadd method to separate lines
- Update model_copy call in reme.py to span multiple lines for better readability
- Format aadd_batch call in update_profile.py to span multiple lines
---
.../personal/personal_retriever.py | 130 ++++---
.../personal/personal_retriever.yaml | 33 +-
.../personal/personal_summarizer.py | 85 +++--
reme/memory/vector_tools/__init__.py | 4 +
reme/memory/vector_tools/base_memory_tool.py | 22 +-
reme/memory/vector_tools/profiles/__init__.py | 1 +
.../add_draft_and_read_all_profiles.py | 8 +-
.../vector_tools/profiles/add_profile.py | 7 +-
.../vector_tools/profiles/delete_profile.py | 7 +-
.../vector_tools/profiles/profile_handler.py | 318 +++++++++---------
.../profiles/read_all_profiles.py | 7 +-
.../vector_tools/profiles/update_profile.py | 18 +-
.../profiles/update_profiles_v1.py | 11 +-
reme/reme.py | 164 +++++++--
tests/test_reme_memory_error_handling.py | 38 ++-
15 files changed, 544 insertions(+), 309 deletions(-)
diff --git a/reme/memory/vector_based/personal/personal_retriever.py b/reme/memory/vector_based/personal/personal_retriever.py
index b8803717..8b4965f1 100644
--- a/reme/memory/vector_based/personal/personal_retriever.py
+++ b/reme/memory/vector_based/personal/personal_retriever.py
@@ -1,39 +1,19 @@
"""Personal memory retriever agent for retrieving personal memories through vector search."""
+from loguru import logger
+
from ..base_memory_agent import BaseMemoryAgent
-from ....core.enumeration import Role, MemoryType
+from ....core.enumeration import MemoryType, Role
from ....core.op import BaseTool
from ....core.schema import Message
from ....core.utils import format_messages
+_PROFILE_TOOL_NAMES: tuple[str, ...] = ("retrieve_profile", "read_all_profiles")
+_EMPTY_PROFILE_RESULTS: tuple[str, ...] = ("", "No profiles found.", "No new profiles found.")
+
class PersonalRetriever(BaseMemoryAgent):
- """Retrieve personal memories through vector search and history reading.
-
- clear && python benchmark/halumem/eval_reme.py \
- --data_path /Users/yuli/workspace/HaluMem/data/HaluMem-Medium.jsonl \
- --reme_model_name qwen3.5-plus \
- --batch_size 10000 \
- --algo_version default
-
- 📊 Question Answering (with LLM answer):
- Correct (all): 0.8537
- Hallucination (all): 0.1159
- Omission (all): 0.0305
- Correct (valid): 0.8537
- Hallucination (valid): 0.1159
- Omission (valid): 0.0305
- Valid/Total: 164/164
-
- 📊 Question Answering (with original memories):
- Correct (all): 0.9085
- Hallucination (all): 0.0671
- Omission (all): 0.0244
- Correct (valid): 0.9085
- Hallucination (valid): 0.0671
- Omission (valid): 0.0244
- Valid/Total: 164/164
- """
+ """Retrieve personal memories through vector search and history reading."""
memory_type: MemoryType = MemoryType.PERSONAL
@@ -41,36 +21,73 @@ class PersonalRetriever(BaseMemoryAgent):
super().__init__(**kwargs)
self.return_memory_nodes: bool = return_memory_nodes
- async def build_messages(self) -> list[Message]:
+ def _get_context(self) -> str:
if self.context.get("query"):
- context = self.context.query
- elif self.context.get("messages"):
- context = self.description + "\n" + format_messages(self.context.messages)
- else:
- raise ValueError("input must have either `query` or `messages`")
-
- read_all_profiles_tool: BaseTool | None = self.pop_tool("read_all_profiles")
- if read_all_profiles_tool is not None:
- all_profiles = await read_all_profiles_tool.call(
- memory_target=self.memory_target,
- service_context=self.service_context,
- )
- else:
- all_profiles = ""
+ return self.context.query.strip()
+ if self.context.get("messages"):
+ return (self.description + "\n" + format_messages(self.context.messages)).strip()
+ raise ValueError("input must have either `query` or `messages`")
+ async def _build_s1_messages(self, context: str) -> list[Message]:
return [
Message(
role=Role.USER,
content=self.prompt_format(
- prompt_name="user_message",
+ prompt_name="user_message_s1",
memory_type=self.memory_type.value,
memory_target=self.memory_target,
- user_profile=all_profiles,
- context=context.strip(),
+ context=context,
),
),
]
+ async def _build_s2_messages(self, context: str, profiles: str) -> list[Message]:
+ return [
+ Message(
+ role=Role.USER,
+ content=self.prompt_format(
+ prompt_name="user_message_s2",
+ memory_type=self.memory_type.value,
+ memory_target=self.memory_target,
+ profiles=profiles,
+ context=context,
+ ),
+ ),
+ ]
+
+ def _partition_tools(self) -> tuple[list[BaseTool], list[BaseTool]]:
+ profile_tools: list[BaseTool] = []
+ memory_tools: list[BaseTool] = []
+ for i, tool in enumerate(self.tools):
+ name = tool.tool_call.name
+ if name in _PROFILE_TOOL_NAMES:
+ profile_tools.append(tool)
+ else:
+ memory_tools.append(tool)
+ logger.info(f"[{self.__class__.__name__}] tool_call[{i}]={tool.tool_call.simple_input_dump(as_dict=False)}")
+ return profile_tools, memory_tools
+
+ @staticmethod
+ def _extract_profile_context(tools: list[BaseTool]) -> str:
+ outputs = []
+ for tool in tools:
+ response = getattr(tool, "response", None)
+ answer = getattr(response, "answer", "")
+ if answer and answer not in _EMPTY_PROFILE_RESULTS:
+ outputs.append(answer)
+ return "\n".join(outputs)
+
+ async def _run_stage(
+ self,
+ stage: str,
+ messages: list[Message],
+ tools: list[BaseTool],
+ ) -> tuple[list[BaseTool], list[Message], bool]:
+ for message in messages:
+ role = message.name or message.role
+ logger.info(f"[{self.__class__.__name__} {stage}] role={role} {message.simple_dump(as_dict=False)}")
+ return await self.react(messages, tools, stage=stage)
+
async def _acting_step(
self,
assistant_message: Message,
@@ -91,7 +108,28 @@ class PersonalRetriever(BaseMemoryAgent):
)
async def execute(self):
- result = await super().execute()
+ context = self._get_context()
+ profile_tools, memory_tools = self._partition_tools()
+
+ tools_s1: list[BaseTool] = []
+ messages_s1: list[Message] = []
+ success_s1 = True
+ profiles = ""
+ if profile_tools:
+ messages_s1 = await self._build_s1_messages(context)
+ tools_s1, messages_s1, success_s1 = await self._run_stage("s1-profile", messages_s1, profile_tools)
+ profiles = self._extract_profile_context(tools_s1)
+
+ messages_s2 = await self._build_s2_messages(context, profiles)
+ tools_s2, messages_s2, success_s2 = await self._run_stage("s2-memory", messages_s2, memory_tools)
+
+ answer = messages_s2[-1].content if success_s2 and messages_s2 else ""
+ result = {
+ "answer": answer,
+ "success": success_s1 and success_s2,
+ "messages": messages_s1 + messages_s2,
+ "tools": tools_s1 + tools_s2,
+ }
if self.return_memory_nodes:
result["answer"] = "\n".join(
[
diff --git a/reme/memory/vector_based/personal/personal_retriever.yaml b/reme/memory/vector_based/personal/personal_retriever.yaml
index 99df1374..c52ae16f 100644
--- a/reme/memory/vector_based/personal/personal_retriever.yaml
+++ b/reme/memory/vector_based/personal/personal_retriever.yaml
@@ -1,8 +1,26 @@
-user_message: |
+user_message_s1: |
+ You are a Profile Retrieval Agent specialized in finding profile information about {memory_target}.
+
+ ## User Question
+ {context}
+
+ ## Task
+ Use the available profile tool to search for profile content that is relevant to the user question.
+
+ ## Instructions
+ - If `retrieve_profile` is available, use it to search with focused profile queries derived from the question
+ - If `read_all_profiles` is available, use it to inspect the full profile list and identify relevant rows
+ - Focus on profile attributes such as identity, location, work, education, preferences, relationships, and other long-term facts
+ - Only retrieve information that is directly relevant to the user question
+ - If no relevant profile information exists, say so clearly
+
+ Output a concise summary of the relevant profile information you found.
+
+user_message_s2: |
You are a Memory Retrieval Agent specialized in retrieving {memory_type} memories about {memory_target}.
- ## User Profile
- {user_profile}
+ ## Profile Search Results
+ {profiles}
## User Question
{context}
@@ -14,11 +32,12 @@ user_message: |
**Tool**: `retrieve_memory` (without time constraints)
**Objective**: Cast a wide net to find potentially relevant memories
**Approach**:
+ - Use the profile search results above as supporting context when forming retrieval queries
- Execute 3-5 diverse search queries using different formulations:
* Original question verbatim
* Rephrased variations (different wording, synonyms)
* Entity-focused queries (extract and search specific names, places, events)
- * Keyword-based searches (core concepts, topics)
+ * Keyword-based searches (core concepts and profile facts)
* Related context queries (broader themes)
### Phase 2(Optional): Temporal Search
@@ -31,7 +50,7 @@ user_message: |
- After date: `20200101,99999999` (from 20200101 onwards)
**Approach**:
- Identify temporal constraints from the user question
- - Refine Phase 1 queries with 3-5 diverse appropriate different time filters
+ - Refine Phase 1 queries with 3-5 diverse appropriate time filters
### Phase 3: Deep Dive into History
**Tool**: `read_history`
@@ -48,11 +67,11 @@ user_message: |
- Use this to understand the full conversation surrounding a memory
## Response Guidelines
- - Base your answer EXCLUSIVELY on user profile, retrieved memories, and history data
+ - Base your answer EXCLUSIVELY on the profile search results, retrieved memories, and history data
- Never infer, assume, or hallucinate information
- Always cite sources with timestamps: `[timestamp] Memory content`
- Present conflicting information transparently with respective timestamps
- If you find sufficient information to answer the user's question, you may output directly without exhausting all search phases
- Exhaust all search strategies before concluding information doesn't exist
- Output a summary of all retrieved memories, user profile, and history data.
+ Output a summary of all retrieved memories and relevant history data.
diff --git a/reme/memory/vector_based/personal/personal_summarizer.py b/reme/memory/vector_based/personal/personal_summarizer.py
index 8e570718..89b1316f 100644
--- a/reme/memory/vector_based/personal/personal_summarizer.py
+++ b/reme/memory/vector_based/personal/personal_summarizer.py
@@ -3,13 +3,17 @@
from loguru import logger
from ..base_memory_agent import BaseMemoryAgent
-from ....core.enumeration import Role, MemoryType
+from ....core.enumeration import MemoryType, Role
from ....core.op import BaseTool
from ....core.schema import Message
+# Optional profile tools used to pre-load profile context; consumed by the
+# summarizer itself and never exposed to the stage-two ReAct loop.
+_PROFILE_CONTEXT_TOOLS: tuple[str, ...] = ("retrieve_profile", "read_all_profiles")
+
class PersonalSummarizer(BaseMemoryAgent):
- """Two-phase personal memory processor: retrieve/add memories then update profile."""
+ """Two-phase personal memory processor: add memories, then update profiles."""
memory_type: MemoryType = MemoryType.PERSONAL
@@ -62,62 +66,71 @@ class PersonalSummarizer(BaseMemoryAgent):
**kwargs,
)
- async def execute(self):
- memory_tools = []
- profile_tools = []
- read_all_profiles_tool: BaseTool | None = None
+ def _partition_tools(self) -> tuple[list[BaseTool], list[BaseTool], BaseTool | None]:
+ """Split attached tools into memory tools, profile tools, and a profile context tool."""
+ memory_tools: list[BaseTool] = []
+ profile_tools: list[BaseTool] = []
+ profile_context_tool: BaseTool | None = None
for i, tool in enumerate(self.tools):
- tool_name = tool.tool_call.name
- if tool_name == "read_all_profiles":
- read_all_profiles_tool = tool
- elif "_memory" in tool_name:
+ name = tool.tool_call.name
+ if name in _PROFILE_CONTEXT_TOOLS:
+ profile_context_tool = tool
+ elif "_memory" in name:
memory_tools.append(tool)
- elif "_profile" in tool_name:
+ elif "_profile" in name:
profile_tools.append(tool)
else:
- raise ValueError(f"[{self.__class__.__name__}] unknown tool_name={tool_name}")
+ raise ValueError(f"[{self.__class__.__name__}] unknown tool_name={name}")
logger.info(f"[{self.__class__.__name__}] tool_call[{i}]={tool.tool_call.simple_input_dump(as_dict=False)}")
+ return memory_tools, profile_tools, profile_context_tool
- stage = "s1-memory"
- messages_s1 = await self._build_s1_messages()
- for i, message in enumerate(messages_s1):
+ async def _preload_user_profile(self, tool: BaseTool | None) -> str:
+ """Invoke the profile context tool to obtain inline profile text."""
+ if tool is None:
+ return ""
+ call_kwargs: dict = {
+ "memory_target": self.memory_target,
+ "service_context": self.service_context,
+ "retrieved_nodes": self.retrieved_nodes,
+ }
+ if tool.tool_call.name == "retrieve_profile":
+ call_kwargs["query"] = self.context.history_node.content
+ return await tool.call(**call_kwargs)
+
+ async def _run_stage(
+ self,
+ stage: str,
+ messages: list[Message],
+ tools: list[BaseTool],
+ ) -> tuple[list[BaseTool], list[Message], bool]:
+ for message in messages:
role = message.name or message.role
logger.info(f"[{self.__class__.__name__} {stage}] role={role} {message.simple_dump(as_dict=False)}")
- tools_s1, messages_s1, success_s1 = await self.react(messages_s1, memory_tools, stage=stage)
+ return await self.react(messages, tools, stage=stage)
- if read_all_profiles_tool is not None:
- profiles = await read_all_profiles_tool.call(
- memory_target=self.memory_target,
- service_context=self.service_context,
- )
- else:
- profiles = ""
+ async def execute(self):
+ memory_tools, profile_tools, profile_context_tool = self._partition_tools()
+
+ messages_s1 = await self._build_s1_messages()
+ tools_s1, messages_s1, success_s1 = await self._run_stage("s1-memory", messages_s1, memory_tools)
if profile_tools:
- stage = "s2-profile"
+ profiles = await self._preload_user_profile(profile_context_tool)
messages_s2 = await self._build_s2_messages(profiles)
- for i, message in enumerate(messages_s2):
- role = message.name or message.role
- logger.info(f"[{self.__class__.__name__} {stage}] role={role} {message.simple_dump(as_dict=False)}")
- tools_s2, messages_s2, success_s2 = await self.react(messages_s2, profile_tools, stage=stage)
+ tools_s2, messages_s2, success_s2 = await self._run_stage("s2-profile", messages_s2, profile_tools)
else:
tools_s2, messages_s2, success_s2 = [], [], True
answer = (messages_s1[-1].content if success_s1 and messages_s1 else "") + (
messages_s2[-1].content if success_s2 and messages_s2 else ""
)
- success = success_s1 and success_s2
- messages = messages_s1 + messages_s2
tools = tools_s1 + tools_s2
- memory_nodes = []
- for tool in tools:
- if tool.memory_nodes:
- memory_nodes.extend(tool.memory_nodes)
+ memory_nodes = [node for tool in tools for node in (tool.memory_nodes or [])]
return {
"answer": answer,
- "success": success,
- "messages": messages,
+ "success": success_s1 and success_s2,
+ "messages": messages_s1 + messages_s2,
"tools": tools,
"memory_nodes": memory_nodes,
}
diff --git a/reme/memory/vector_tools/__init__.py b/reme/memory/vector_tools/__init__.py
index fd5e721f..9fb04923 100644
--- a/reme/memory/vector_tools/__init__.py
+++ b/reme/memory/vector_tools/__init__.py
@@ -1,5 +1,7 @@
"""memory tools"""
+# pylint: disable=no-name-in-module
+
from .base_memory_tool import BaseMemoryTool
# chunk tools
@@ -15,6 +17,7 @@ from .profiles.add_draft_and_read_all_profiles import AddDraftAndReadAllProfiles
from .profiles.add_profile import AddProfile
from .profiles.delete_profile import DeleteProfile
from .profiles.read_all_profiles import ReadAllProfiles
+from .profiles.retrieve_profile import RetrieveProfile
from .profiles.update_profile import UpdateProfile
from .profiles.update_profiles_v1 import UpdateProfilesV1
@@ -43,6 +46,7 @@ __all__ = [
"AddProfile",
"DeleteProfile",
"ReadAllProfiles",
+ "RetrieveProfile",
"UpdateProfile",
"UpdateProfilesV1",
# record tools
diff --git a/reme/memory/vector_tools/base_memory_tool.py b/reme/memory/vector_tools/base_memory_tool.py
index 3431b164..f0781bd3 100644
--- a/reme/memory/vector_tools/base_memory_tool.py
+++ b/reme/memory/vector_tools/base_memory_tool.py
@@ -3,6 +3,7 @@
from abc import ABCMeta
from pathlib import Path
+from .profiles.profile_handler import ProfileHandler
from ...core.enumeration import MemoryType
from ...core.op import BaseTool
from ...core.schema import ToolCall, MemoryNode, ToolAttr
@@ -16,12 +17,18 @@ class BaseMemoryTool(BaseTool, metaclass=ABCMeta):
enable_multiple: bool = True,
enable_thinking_params: bool = False,
profile_dir: str = "",
+ profile_backend: str = "filesystem",
+ profile_store_name: str = "profile",
+ profile_max_capacity: int = 50,
**kwargs,
):
super().__init__(**kwargs)
self.enable_multiple: bool = enable_multiple
self.enable_thinking_params: bool = enable_thinking_params
self.profile_dir: str = profile_dir
+ self.profile_backend: str = profile_backend
+ self.profile_store_name: str = profile_store_name
+ self.profile_max_capacity: int = profile_max_capacity
def _build_tool_call(self) -> ToolCall:
"""Build and return the tool call schema"""
@@ -103,6 +110,19 @@ class BaseMemoryTool(BaseTool, metaclass=ABCMeta):
return self.context.service_context.memory_target_type_mapping
@property
- def profile_path(self) -> Path:
+ def profile_path(self) -> Path | None:
"""Get the path to the profile directory for the current collection."""
+ if not self.profile_dir:
+ return None
return Path(self.profile_dir) / self.vector_store.collection_name
+
+ def get_profile_handler(self, memory_target: str) -> ProfileHandler:
+ """Build a profile handler for the current backend configuration."""
+ return ProfileHandler(
+ memory_target=memory_target,
+ profile_path=self.profile_path,
+ service_context=self.service_context,
+ profile_backend=self.profile_backend,
+ profile_store_name=self.profile_store_name,
+ max_capacity=self.profile_max_capacity,
+ )
diff --git a/reme/memory/vector_tools/profiles/__init__.py b/reme/memory/vector_tools/profiles/__init__.py
index e69de29b..150455db 100644
--- a/reme/memory/vector_tools/profiles/__init__.py
+++ b/reme/memory/vector_tools/profiles/__init__.py
@@ -0,0 +1 @@
+"""Profile memory tools."""
diff --git a/reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py b/reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py
index 716c4e5e..c8e4889a 100644
--- a/reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py
+++ b/reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py
@@ -1,8 +1,7 @@
-"""Add draft profile and read all profiles from local storage"""
+"""Add draft profile and read all profiles from the configured backend."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -92,9 +91,8 @@ class AddDraftAndReadAllProfiles(BaseMemoryTool):
continue
targets_processed.add(target)
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=target)
-
- profiles_str = profile_handler.read_all(add_profile_id=True)
+ profile_handler = self.get_profile_handler(target)
+ profiles_str = await profile_handler.aread_all(add_profile_id=True)
if profiles_str:
all_profiles.append(f"## Profiles for {target}:\n{profiles_str}")
diff --git a/reme/memory/vector_tools/profiles/add_profile.py b/reme/memory/vector_tools/profiles/add_profile.py
index 880bcf45..5654ebb7 100644
--- a/reme/memory/vector_tools/profiles/add_profile.py
+++ b/reme/memory/vector_tools/profiles/add_profile.py
@@ -1,8 +1,7 @@
-"""Add user profile tool"""
+"""Add user profile tool."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -40,7 +39,7 @@ class AddProfile(BaseMemoryTool):
)
async def execute(self):
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=self.memory_target)
+ profile_handler = self.get_profile_handler(self.memory_target)
# Get parameters
message_time = self.context.get("message_time", "")
@@ -58,7 +57,7 @@ class AddProfile(BaseMemoryTool):
}
# Add profile using ProfileHandler
- new_nodes = profile_handler.add_batch(profiles=[profile], ref_memory_id=self.history_id)
+ new_nodes = await profile_handler.aadd_batch(profiles=[profile], ref_memory_id=self.history_id)
self.memory_nodes.extend(new_nodes)
if new_nodes:
diff --git a/reme/memory/vector_tools/profiles/delete_profile.py b/reme/memory/vector_tools/profiles/delete_profile.py
index ae5bbc05..1c5f845b 100644
--- a/reme/memory/vector_tools/profiles/delete_profile.py
+++ b/reme/memory/vector_tools/profiles/delete_profile.py
@@ -1,8 +1,7 @@
-"""Delete user profile tool"""
+"""Delete user profile tool."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -32,7 +31,7 @@ class DeleteProfile(BaseMemoryTool):
)
async def execute(self):
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=self.memory_target)
+ profile_handler = self.get_profile_handler(self.memory_target)
# Get profile_id parameter
profile_id = self.context.get("profile_id", "")
@@ -41,7 +40,7 @@ class DeleteProfile(BaseMemoryTool):
return "No profile_id provided, operation cancelled."
# Delete profile using ProfileHandler
- success = profile_handler.delete(profile_id)
+ success = await profile_handler.adelete(profile_id)
if success:
output = f"Successfully deleted profile with ID: {profile_id}"
diff --git a/reme/memory/vector_tools/profiles/profile_handler.py b/reme/memory/vector_tools/profiles/profile_handler.py
index 3d0df400..0df3707a 100644
--- a/reme/memory/vector_tools/profiles/profile_handler.py
+++ b/reme/memory/vector_tools/profiles/profile_handler.py
@@ -1,195 +1,123 @@
-"""Profile Handler for managing user profiles in local memory"""
+"""Profile handler facade for filesystem and vector backends."""
+# pylint: disable=missing-function-docstring
+
+import asyncio
from pathlib import Path
from loguru import logger
-from ....core.enumeration import MemoryType
+from .file_profile_backend import FileProfileBackend
+from .profile_backend import BaseProfileBackend
+from .vector_profile_backend import VectorProfileBackend
+from ....core import ServiceContext
from ....core.schema import MemoryNode
-from ....core.utils import CacheHandler, deduplicate_memories
class ProfileHandler:
- """User profile CRUD handler"""
+ """User profile facade with pluggable storage backends."""
- def __init__(self, profile_path: str | Path, memory_target: str, max_capacity: int = 50):
- """init"""
- self.memory_target: str = memory_target
- self.cache_key: str = self.memory_target.replace(" ", "_").lower()
- self.cache_handler: CacheHandler = CacheHandler(profile_path)
- self.max_capacity: int = max_capacity
-
- def _load_nodes(self) -> list[MemoryNode]:
- """Load profile nodes"""
- cached_data = self.cache_handler.load(self.cache_key, auto_clean=False)
- if not cached_data:
- return []
- return [MemoryNode(**data) for data in cached_data]
-
- def _save_nodes(self, nodes: list[MemoryNode], apply_limits: bool = True):
- """Save nodes with optional deduplication and capacity enforcement"""
- if apply_limits:
- nodes = deduplicate_memories(nodes)
-
- # Enforce capacity limit by removing the oldest profiles
- if len(nodes) > self.max_capacity:
- sorted_nodes = sorted(nodes, key=lambda n: n.message_time)
- removed_count = len(sorted_nodes) - self.max_capacity
- nodes = sorted_nodes[removed_count:]
- logger.info(
- f"Capacity limit reached: removed {removed_count} oldest profiles "
- f"(kept {len(nodes)}/{self.max_capacity})",
- )
-
- nodes_data = [node.model_dump(exclude_none=True) for node in nodes]
- self.cache_handler.save(self.cache_key, nodes_data)
- logger.info(f"Saved {len(nodes)} profiles to {self.cache_key}")
-
- def delete(self, profile_id: str | list[str]) -> bool | int:
- """Delete profile by ID(s), returns True/False for single ID or count for batch delete"""
- nodes = self._load_nodes()
- original_count = len(nodes)
-
- # Batch delete mode
- if isinstance(profile_id, list):
- profile_ids_set = set(profile_id)
- nodes = [n for n in nodes if n.memory_id not in profile_ids_set]
- deleted_count = original_count - len(nodes)
-
- if deleted_count == 0:
- logger.warning(f"No profiles found to delete from {len(profile_id)} IDs")
- return 0
-
- self._save_nodes(nodes, apply_limits=False)
- logger.info(f"Batch deleted {deleted_count} profiles")
- return deleted_count
-
- # Single delete mode
- nodes = [n for n in nodes if n.memory_id != profile_id]
-
- if len(nodes) == original_count:
- logger.warning(f"Profile {profile_id} not found")
- return False
-
- self._save_nodes(nodes, apply_limits=False)
- logger.info(f"Deleted profile {profile_id}")
- return True
-
- def delete_all(self) -> int:
- """Delete all profiles, returns count deleted"""
- nodes = self._load_nodes()
- count = len(nodes)
- self._save_nodes([], apply_limits=False)
- logger.info(f"Deleted all {count} profiles")
- return count
-
- def add(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
- """Add new profile, returns created MemoryNode"""
- nodes = self._load_nodes()
-
- new_node = MemoryNode(
- memory_type=MemoryType.PERSONAL,
- memory_target=self.memory_target,
- when_to_use=profile_key,
- content=profile_value,
- message_time=message_time,
- ref_memory_id=ref_memory_id,
+ def __init__(
+ self,
+ memory_target: str,
+ profile_path: str | Path | None = None,
+ service_context: ServiceContext | None = None,
+ profile_backend: str = "filesystem",
+ profile_store_name: str = "profile",
+ max_capacity: int = 50,
+ ):
+ self.memory_target = memory_target
+ self.profile_backend = profile_backend
+ self.profile_store_name = profile_store_name
+ self.max_capacity = max_capacity
+ self.cache_key = self.memory_target.replace(" ", "_").lower()
+ self.backend = self._build_backend(
+ profile_path=profile_path,
+ service_context=service_context,
)
- # Remove existing nodes with the same when_to_use (profile_key)
- original_count = len(nodes)
- nodes = [n for n in nodes if n.when_to_use != profile_key]
- if len(nodes) < original_count:
- logger.info(f"Removed {original_count - len(nodes)} duplicate profile(s) with key: {profile_key}")
-
- nodes.append(new_node)
- self._save_nodes(nodes)
- logger.info(f"Added profile: {profile_key}={profile_value}")
- return new_node
-
- def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
- """Add multiple profiles in batch, returns list of created MemoryNodes"""
- if not profiles:
- return []
-
- nodes = self._load_nodes()
-
- new_nodes = [
- MemoryNode(
- memory_type=MemoryType.PERSONAL,
+ def _build_backend(
+ self,
+ profile_path: str | Path | None,
+ service_context: ServiceContext | None,
+ ) -> BaseProfileBackend:
+ if self.profile_backend == "filesystem":
+ if profile_path is None:
+ raise ValueError("profile_path is required for filesystem profile backend")
+ return FileProfileBackend(
+ profile_path=profile_path,
memory_target=self.memory_target,
- when_to_use=p.get("profile_key", ""),
- content=p.get("profile_value", ""),
- message_time=p.get("message_time", ""),
- ref_memory_id=ref_memory_id,
+ max_capacity=self.max_capacity,
)
- for p in profiles
- ]
- # Remove existing nodes with the same when_to_use (profile_key)
- new_keys = {n.when_to_use for n in new_nodes}
- original_count = len(nodes)
- nodes = [n for n in nodes if n.when_to_use not in new_keys]
- if len(nodes) < original_count:
- logger.info(f"Removed {original_count - len(nodes)} duplicate profile(s) with matching keys")
+ if self.profile_backend == "vector":
+ if service_context is None:
+ raise ValueError("service_context is required for vector profile backend")
+ return VectorProfileBackend(
+ memory_target=self.memory_target,
+ service_context=service_context,
+ vector_store_name=self.profile_store_name,
+ max_capacity=self.max_capacity,
+ )
- nodes.extend(new_nodes)
- self._save_nodes(nodes)
- logger.info(f"Batch added {len(new_nodes)} profiles")
- return new_nodes
+ raise ValueError(f"Unsupported profile backend: {self.profile_backend}")
- def update(self, profile_id: str, message_time: str, profile_key: str, profile_value: str) -> MemoryNode | None:
- """Update profile by ID, returns updated node or None if not found"""
- nodes = self._load_nodes()
+ @staticmethod
+ def _run_sync(coro):
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(coro)
+ raise RuntimeError(
+ "Synchronous profile access is not available in an active event loop. Use async methods instead.",
+ )
- target_node = None
- for node in nodes:
- if node.memory_id == profile_id:
- node.when_to_use = profile_key
- node.content = profile_value
- node.message_time = message_time
- target_node = node
- break
+ async def adelete(self, profile_id: str | list[str]) -> bool | int:
+ return await self.backend.delete(profile_id)
- if target_node is None:
- logger.warning(f"Profile {profile_id} not found")
- return None
+ async def adelete_all(self) -> int:
+ return await self.backend.delete_all()
- self._save_nodes(nodes, apply_limits=False)
- logger.info(f"Updated profile {profile_id}: {profile_key}={profile_value}")
- return target_node
+ async def aadd(
+ self,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ref_memory_id: str = "",
+ ) -> MemoryNode:
+ return await self.backend.add(message_time, profile_key, profile_value, ref_memory_id)
- def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
- """Get profile by ID or key"""
- if not profile_id and not profile_key:
- raise ValueError("Must provide either profile_id or profile_key")
+ async def aadd_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ return await self.backend.add_batch(profiles, ref_memory_id)
- nodes = self._load_nodes()
- for node in nodes:
- if profile_id and node.memory_id == profile_id:
- return node
- if profile_key and node.when_to_use == profile_key:
- return node
- return None
+ async def aupdate(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ return await self.backend.update(profile_id, message_time, profile_key, profile_value)
- def get_by_id(self, profile_id: str) -> MemoryNode | None:
- """Get profile by ID (convenience method)"""
- return self.get_by(profile_id=profile_id)
+ async def aget_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ return await self.backend.get_by(profile_id=profile_id, profile_key=profile_key)
- def get_by_key(self, profile_key: str) -> MemoryNode | None:
- """Get profile by key (convenience method)"""
- return self.get_by(profile_key=profile_key)
+ async def aget_by_id(self, profile_id: str) -> MemoryNode | None:
+ return await self.aget_by(profile_id=profile_id)
- def get_all(self) -> list[MemoryNode]:
- """Get all profiles, sorted by message_time"""
- nodes = self._load_nodes()
- nodes.sort(key=lambda n: n.message_time)
- return nodes
+ async def aget_by_key(self, profile_key: str) -> MemoryNode | None:
+ return await self.aget_by(profile_key=profile_key)
+
+ async def aget_all(self) -> list[MemoryNode]:
+ return await self.backend.get_all()
+
+ async def asearch(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ return await self.backend.search(query=query, limit=limit)
@staticmethod
def _format_node(node: MemoryNode, add_profile_id: bool = False, add_history_id: bool = False) -> str:
- """Format a single node to string"""
parts = []
+ profile_key = str(node.metadata.get("profile_key", node.when_to_use))
if add_profile_id:
parts.append(f"profile_id={node.memory_id}")
@@ -197,16 +125,70 @@ class ProfileHandler:
if node.message_time:
parts.append(f"[{node.message_time}]")
- parts.append(f"{node.when_to_use}: {node.content}")
+ parts.append(f"{profile_key}: {node.content}")
- if add_history_id:
+ if add_history_id and node.ref_memory_id:
parts.append(f"history_id={node.ref_memory_id}")
return " ".join(parts)
- def read_all(self, add_profile_id: bool = False, add_history_id: bool = False) -> str:
- """Read all profiles and return formatted string"""
- nodes = self.get_all()
+ async def aread_all(self, add_profile_id: bool = False, add_history_id: bool = False) -> str:
+ nodes = await self.aget_all()
formatted_profiles = [self._format_node(node, add_profile_id, add_history_id) for node in nodes]
logger.info(f"Read {len(formatted_profiles)} profiles from {self.cache_key}")
return "\n".join(formatted_profiles).strip()
+
+ async def aretrieve(
+ self,
+ query: str | list[str],
+ limit: int = 5,
+ add_profile_id: bool = True,
+ add_history_id: bool = False,
+ ) -> tuple[list[MemoryNode], str]:
+ nodes = await self.asearch(query=query, limit=limit)
+ formatted_profiles = [self._format_node(node, add_profile_id, add_history_id) for node in nodes]
+ return nodes, "\n".join(formatted_profiles).strip()
+
+ def delete(self, profile_id: str | list[str]) -> bool | int:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.delete_sync(profile_id)
+ return self._run_sync(self.adelete(profile_id))
+
+ def delete_all(self) -> int:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.delete_all_sync()
+ return self._run_sync(self.adelete_all())
+
+ def add(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.add_sync(message_time, profile_key, profile_value, ref_memory_id)
+ return self._run_sync(self.aadd(message_time, profile_key, profile_value, ref_memory_id))
+
+ def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.add_batch_sync(profiles, ref_memory_id)
+ return self._run_sync(self.aadd_batch(profiles, ref_memory_id))
+
+ def update(self, profile_id: str, message_time: str, profile_key: str, profile_value: str) -> MemoryNode | None:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.update_sync(profile_id, message_time, profile_key, profile_value)
+ return self._run_sync(self.aupdate(profile_id, message_time, profile_key, profile_value))
+
+ def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.get_by_sync(profile_id=profile_id, profile_key=profile_key)
+ return self._run_sync(self.aget_by(profile_id=profile_id, profile_key=profile_key))
+
+ def get_by_id(self, profile_id: str) -> MemoryNode | None:
+ return self._run_sync(self.aget_by_id(profile_id))
+
+ def get_by_key(self, profile_key: str) -> MemoryNode | None:
+ return self._run_sync(self.aget_by_key(profile_key))
+
+ def get_all(self) -> list[MemoryNode]:
+ if isinstance(self.backend, FileProfileBackend):
+ return self.backend.get_all_sync()
+ return self._run_sync(self.aget_all())
+
+ def read_all(self, add_profile_id: bool = False, add_history_id: bool = False) -> str:
+ return self._run_sync(self.aread_all(add_profile_id, add_history_id))
diff --git a/reme/memory/vector_tools/profiles/read_all_profiles.py b/reme/memory/vector_tools/profiles/read_all_profiles.py
index 1d7ddb1a..50eab684 100644
--- a/reme/memory/vector_tools/profiles/read_all_profiles.py
+++ b/reme/memory/vector_tools/profiles/read_all_profiles.py
@@ -1,8 +1,7 @@
-"""Read user profile tool"""
+"""Read user profile tool."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -44,8 +43,8 @@ class ReadAllProfiles(BaseMemoryTool):
else:
target = self.memory_target
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=target)
- profiles_str = profile_handler.read_all(add_profile_id=True)
+ profile_handler = self.get_profile_handler(target)
+ profiles_str = await profile_handler.aread_all(add_profile_id=True)
if not profiles_str:
output = "No profiles found."
logger.info(output)
diff --git a/reme/memory/vector_tools/profiles/update_profile.py b/reme/memory/vector_tools/profiles/update_profile.py
index 37512714..04c932bf 100644
--- a/reme/memory/vector_tools/profiles/update_profile.py
+++ b/reme/memory/vector_tools/profiles/update_profile.py
@@ -1,8 +1,7 @@
-"""Update user profile tool"""
+"""Update user profile tool."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -82,8 +81,8 @@ class UpdateProfile(BaseMemoryTool):
# Delete profiles (using self.memory_target)
if profile_ids_to_delete:
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=self.memory_target)
- removed_count = profile_handler.delete(profile_ids_to_delete)
+ profile_handler = self.get_profile_handler(self.memory_target)
+ removed_count = await profile_handler.adelete(profile_ids_to_delete)
# Add new profiles
if profiles_to_add:
@@ -98,14 +97,17 @@ class UpdateProfile(BaseMemoryTool):
# Add profiles for each target
for target, target_profiles in profiles_by_target.items():
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=target)
- new_nodes = profile_handler.add_batch(profiles=target_profiles, ref_memory_id=self.history_id)
+ profile_handler = self.get_profile_handler(target)
+ new_nodes = await profile_handler.aadd_batch(
+ profiles=target_profiles,
+ ref_memory_id=self.history_id,
+ )
self.memory_nodes.extend(new_nodes)
added_count += len(new_nodes)
else:
# Use self.memory_target for all profiles
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=self.memory_target)
- new_nodes = profile_handler.add_batch(profiles=profiles_to_add, ref_memory_id=self.history_id)
+ profile_handler = self.get_profile_handler(self.memory_target)
+ new_nodes = await profile_handler.aadd_batch(profiles=profiles_to_add, ref_memory_id=self.history_id)
self.memory_nodes.extend(new_nodes)
added_count = len(new_nodes)
diff --git a/reme/memory/vector_tools/profiles/update_profiles_v1.py b/reme/memory/vector_tools/profiles/update_profiles_v1.py
index d38b3747..128188fb 100644
--- a/reme/memory/vector_tools/profiles/update_profiles_v1.py
+++ b/reme/memory/vector_tools/profiles/update_profiles_v1.py
@@ -1,8 +1,7 @@
-"""Update user profile tool"""
+"""Update user profile tool."""
from loguru import logger
-from .profile_handler import ProfileHandler
from ..base_memory_tool import BaseMemoryTool
from ....core.schema import ToolCall
@@ -113,8 +112,8 @@ class UpdateProfilesV1(BaseMemoryTool):
for target, profile_ids in delete_by_target.items():
if profile_ids:
profile_ids = sorted(set(profile_ids)) # Remove duplicates and sort
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=target)
- profile_handler.delete(profile_ids)
+ profile_handler = self.get_profile_handler(target)
+ await profile_handler.adelete(profile_ids)
# Step 2: Prepare all profiles to add (both updated and new)
all_profiles_to_add = []
@@ -158,8 +157,8 @@ class UpdateProfilesV1(BaseMemoryTool):
added_count = len(profiles_to_add)
for target, target_profiles in profiles_by_target.items():
- profile_handler = ProfileHandler(profile_path=self.profile_path, memory_target=target)
- new_nodes = profile_handler.add_batch(profiles=target_profiles, ref_memory_id=self.history_id)
+ profile_handler = self.get_profile_handler(target)
+ new_nodes = await profile_handler.aadd_batch(profiles=target_profiles, ref_memory_id=self.history_id)
all_memory_nodes.extend(new_nodes)
# Extend memory_nodes for tracking
diff --git a/reme/reme.py b/reme/reme.py
index f559ad71..c7c04d2e 100644
--- a/reme/reme.py
+++ b/reme/reme.py
@@ -14,6 +14,7 @@ from .memory.vector_tools import (
DelegateTask,
ReadAllProfiles,
ReadHistory,
+ RetrieveProfile,
RetrieveMemory,
UpdateProfilesV1,
)
@@ -55,6 +56,10 @@ class ReMe(Application):
target_task_names: list[str] | None = None,
target_tool_names: list[str] | None = None,
enable_profile: bool = True,
+ profile_backend: str = "filesystem",
+ profile_store_name: str = "profile",
+ profile_collection_name: str | None = None,
+ profile_max_capacity: int = 50,
**kwargs,
):
"""Initialize ReMe with config.
@@ -69,8 +74,37 @@ class ReMe(Application):
```
Args:
+ *args: Positional arguments forwarded to the base `Application`.
+ llm_api_key: API key used by the default LLM backend when provided.
+ llm_base_url: Base URL used by the default LLM backend when provided.
+ embedding_api_key: API key used by the default embedding backend when provided.
+ embedding_base_url: Base URL used by the default embedding backend when provided.
+ working_dir: Directory for generated config, logs, caches, and local stores.
+ config_path: Built-in config name or config file path used to initialize services.
+ enable_logo: Whether to print the ReMe logo during startup.
+ log_to_console: Whether to emit logs to the console.
+ log_to_file: Whether to write logs under `working_dir`.
+ default_llm_config: Overrides for the default LLM configuration.
+ default_embedding_model_config: Overrides for the default embedding model configuration.
+ default_vector_store_config: Configuration for the default memory vector store.
+ Its `collection_name` is used for normal memory storage.
+ default_token_counter_config: Overrides for the default token counter configuration.
+ target_user_names: Personal memory targets to register at initialization.
+ target_task_names: Procedural memory targets to register at initialization.
+ target_tool_names: Tool memory targets to register at initialization.
enable_profile: Whether to enable profile functionality. Set to False when using
- cloud-based vector stores to avoid local file operations. Default is True.
+ profile-free memory flows.
+ profile_backend: Profile storage backend. Use "filesystem" for local JSONL profile
+ files or "vector" for a dedicated profile vector collection.
+ profile_store_name: Internal vector store key used to register and look up the
+ profile vector store in `service_context.vector_stores`. This is not the
+ database collection name.
+ profile_collection_name: Dedicated database collection/table name for vector
+ profiles. When unset, vector profiles use the default memory collection name
+ with a "_profile" suffix.
+ profile_max_capacity: Maximum number of profile rows to keep per memory target.
+ When the limit is exceeded, the oldest profile rows are removed.
+ **kwargs: Additional keyword arguments forwarded to the base `Application`.
"""
super().__init__(
*args,
@@ -92,6 +126,10 @@ class ReMe(Application):
)
self.enable_profile = enable_profile
+ self.profile_backend = profile_backend
+ self.profile_store_name = profile_store_name
+ self.profile_collection_name = profile_collection_name
+ self.profile_max_capacity = profile_max_capacity
memory_target_type_mapping: dict[str, MemoryType] = {}
if target_user_names:
@@ -111,13 +149,16 @@ class ReMe(Application):
self.service_context.memory_target_type_mapping = memory_target_type_mapping
- if self.enable_profile:
+ if self.enable_profile and self.profile_backend == "filesystem":
profile_path = Path(self.service_context.service_config.working_dir) / "profile"
profile_path.mkdir(parents=True, exist_ok=True)
self.profile_dir: str = str(profile_path)
else:
self.profile_dir: str = ""
+ if self.enable_profile and self.profile_backend == "vector":
+ self._ensure_profile_vector_store_config()
+
def _add_meta_memory(self, memory_type: str | MemoryType, memory_target: str):
"""Register or validate a memory target with the given memory type."""
if memory_target in self.service_context.memory_target_type_mapping:
@@ -186,6 +227,38 @@ class ReMe(Application):
return result
return result["answer"]
+ def _ensure_profile_vector_store_config(self) -> None:
+ """Ensure the dedicated profile vector store exists in service config."""
+ vector_store_configs = self.service_context.service_config.vector_stores
+ if "default" not in vector_store_configs:
+ raise RuntimeError("Vector profile backend requires a default vector store configuration")
+
+ default_config = vector_store_configs["default"]
+ profile_collection_name = self.profile_collection_name or f"{default_config.collection_name}_profile"
+
+ if self.profile_store_name in vector_store_configs:
+ if self.profile_collection_name:
+ vector_store_configs[self.profile_store_name] = vector_store_configs[
+ self.profile_store_name
+ ].model_copy(
+ update={"collection_name": profile_collection_name},
+ )
+ return
+
+ vector_store_configs[self.profile_store_name] = default_config.model_copy(
+ update={"collection_name": profile_collection_name},
+ )
+
+ def _get_profile_tool_kwargs(self, raise_exception: bool) -> dict:
+ """Shared profile tool configuration."""
+ return {
+ "profile_dir": self.profile_dir,
+ "profile_backend": self.profile_backend,
+ "profile_store_name": self.profile_store_name,
+ "profile_max_capacity": self.profile_max_capacity,
+ "raise_exception": raise_exception,
+ }
+
async def summarize_memory(
self,
messages: list[Message | dict],
@@ -211,6 +284,7 @@ class ReMe(Application):
format_messages.append(message)
if version == "default":
+ profile_tool_kwargs = self._get_profile_tool_kwargs(raise_exception)
personal_summarizer_tools: list = [
AddDraftAndRetrieveSimilarMemory(
enable_thinking_params=enable_thinking_params,
@@ -229,20 +303,28 @@ class ReMe(Application):
),
]
if self.enable_profile:
+ if self.profile_backend == "vector":
+ profile_context_tool = RetrieveProfile(
+ top_k=min(5, retrieve_top_k),
+ enable_thinking_params=False,
+ enable_memory_target=False,
+ enable_multiple=False,
+ **profile_tool_kwargs,
+ )
+ else:
+ profile_context_tool = ReadAllProfiles(
+ enable_thinking_params=False,
+ enable_memory_target=False,
+ **profile_tool_kwargs,
+ )
personal_summarizer_tools.extend(
[
- ReadAllProfiles(
- enable_thinking_params=False,
- enable_memory_target=False,
- profile_dir=self.profile_dir,
- raise_exception=raise_exception,
- ),
+ profile_context_tool,
UpdateProfilesV1(
enable_thinking_params=enable_thinking_params,
enable_memory_target=False,
enable_multiple=True,
- profile_dir=self.profile_dir,
- raise_exception=raise_exception,
+ **profile_tool_kwargs,
),
],
)
@@ -381,16 +463,24 @@ class ReMe(Application):
self._ensure_started()
if version == "default":
+ profile_tool_kwargs = self._get_profile_tool_kwargs(raise_exception)
personal_retriever_tools = []
if self.enable_profile:
- personal_retriever_tools.append(
- ReadAllProfiles(
+ if self.profile_backend == "vector":
+ profile_context_tool = RetrieveProfile(
+ top_k=min(5, retrieve_top_k),
enable_thinking_params=False,
enable_memory_target=False,
- profile_dir=self.profile_dir,
- raise_exception=raise_exception,
- ),
- )
+ enable_multiple=False,
+ **profile_tool_kwargs,
+ )
+ else:
+ profile_context_tool = ReadAllProfiles(
+ enable_thinking_params=False,
+ enable_memory_target=False,
+ **profile_tool_kwargs,
+ )
+ personal_retriever_tools.append(profile_context_tool)
personal_retriever_tools.extend(
[
RetrieveMemory(
@@ -509,6 +599,34 @@ class ReMe(Application):
return self._unwrap_memory_result(result, "retrieve_memory", return_dict)
+ async def retrieve_profile(
+ self,
+ query: str | list[str],
+ user_name: str,
+ top_k: int = 5,
+ return_dict: bool = False,
+ ) -> str | dict:
+ """Retrieve relevant profile rows for a user."""
+ self._ensure_started()
+ if not self.enable_profile:
+ raise RuntimeError("Profile functionality is disabled.")
+
+ profile_handler = self.get_profile_handler(user_name)
+ if profile_handler is None:
+ raise RuntimeError("Profile functionality is disabled.")
+
+ retrieved_nodes, output = await profile_handler.aretrieve(
+ query=query,
+ limit=top_k,
+ add_profile_id=True,
+ add_history_id=True,
+ )
+ result = {
+ "answer": output or "No matching profiles found.",
+ "retrieved_nodes": retrieved_nodes,
+ }
+ return self._unwrap_memory_result(result, "retrieve_profile", return_dict)
+
async def add_memory(
self,
memory_content: str,
@@ -675,15 +793,23 @@ class ReMe(Application):
@property
def profile_path(self) -> Path | None:
"""Get the path to the profile directory. Returns None if profile is disabled."""
- if not self.enable_profile:
+ if not self.enable_profile or self.profile_backend != "filesystem":
return None
- return Path(self.profile_dir) / self.default_vector_store.collection_name
+ collection_name = self.service_context.service_config.vector_stores["default"].collection_name
+ return Path(self.profile_dir) / collection_name
def get_profile_handler(self, user_name: str) -> ProfileHandler | None:
"""Get the profile handler for the specified user. Returns None if profile is disabled."""
if not self.enable_profile:
return None
- return ProfileHandler(memory_target=user_name, profile_path=self.profile_path)
+ return ProfileHandler(
+ memory_target=user_name,
+ profile_path=self.profile_path,
+ service_context=self.service_context,
+ profile_backend=self.profile_backend,
+ profile_store_name=self.profile_store_name,
+ max_capacity=self.profile_max_capacity,
+ )
def main():
diff --git a/tests/test_reme_memory_error_handling.py b/tests/test_reme_memory_error_handling.py
index 99953efd..563e8221 100644
--- a/tests/test_reme_memory_error_handling.py
+++ b/tests/test_reme_memory_error_handling.py
@@ -1,9 +1,14 @@
"""Tests for ReMe memory error handling and raise_exception propagation."""
+from types import SimpleNamespace
+
import pytest
import reme.reme as reme_module
-from reme import ReMe
+from reme.core.runtime_context import RuntimeContext
+from reme.core.schema import MemoryNode
+from reme.memory.vector_tools.history.read_history import ReadHistory
+from reme.reme import ReMe
class Recorder:
@@ -130,3 +135,34 @@ async def test_summarize_memory_raises_runtime_error_for_unstructured_result(mon
messages=[{"role": "user", "content": "hi", "time_created": "2026-03-20 10:00:00"}],
task_name="demo-task",
)
+
+
+@pytest.mark.asyncio
+async def test_read_history_accepts_single_history_id_in_multiple_mode():
+ """Verify multiple-mode history lookup accepts a single history_id string."""
+
+ class FakeVectorStore:
+ """Minimal vector store stub for ReadHistory tests."""
+
+ async def get(self, vector_ids):
+ """Return the requested history node."""
+ assert vector_ids == ["history_123"]
+ node = MemoryNode(
+ memory_id="history_123",
+ memory_type="history",
+ memory_target="alice",
+ content="Alice said hello.",
+ )
+ return [node.to_vector_node()]
+
+ tool = ReadHistory(enable_multiple=True)
+ tool._vector_store = FakeVectorStore() # pylint: disable=protected-access
+ tool.context = RuntimeContext(
+ history_id="history_123",
+ retrieved_nodes=[],
+ service_context=SimpleNamespace(memory_target_type_mapping={"alice": "personal"}),
+ )
+
+ result = await tool.execute()
+
+ assert "Historical Dialogue[history_123]" in result
From 72eabfa858bd331d021310435f77bb718931ad47 Mon Sep 17 00:00:00 2001
From: Zhouwk <57825291+nitwtog@users.noreply.github.com>
Date: Thu, 30 Apr 2026 10:19:36 +0800
Subject: [PATCH 04/16] fix(user profile): update locomo benchmark and update
vector based profile code (#225)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(reme): 添加配置选项以启用或禁用个人资料功能
- 在 ReMe 初始化方法中添加 enable_profile 参数,默认值为 True
- 根据 enable_profile 设置决定是否创建 profile 目录和设置 profile_dir
- 在 PersonalSummarizer 中根据 enable_profile 条件性地添加个人资料相关工具
- 在 PersonalRetriever 中根据 enable_profile 条件性地添加 ReadAllProfiles 工具
- 修改 profile_path 属性以在禁用个人资料时返回 None
- 修改 get_profile_handler 方法以在禁用个人资料时返回 None
- 为 enable_profile 参数添加文档说明其用于云向量存储场景
* refactor(benchmark): 重构LongMemEval基准测试中的ReMe实例管理
- 移除未使用的shutil导入
- 将固定的ReMe实例改为每个问题创建独立实例以实现隔离
- 更新LLM配置名称从qwen3-max-think到qwen-max-t
- 修改模型调用逻辑使用正确的model_name参数
- 添加qwen-flash和GPT-4o-mini等新模型配置
- 统一使用"User"作为用户名,通过集合名实现隔离
- 调整并发处理数从4降至1,批处理大小从10增至30
- 每个问题类型采样数从2增至4
- 添加异步上下文管理确保资源正确释放
* reformat 2 files
* refactor(benchmark): 重构长记忆评估中的模型配置
- 将原有的 eval_model_name 替换为专门的 retrieve_model_name 用于检索操作
- 添加对 qwen-max 模型配置的支持
- 更新参数解析器以支持新的检索模型参数
- 修改最大并发数默认值从 1 提升到 4
- 调整样本数量默认值从 4 减少到 1
- 统一模型参数命名规范,区分摘要、检索和评估模型
- 优化内存处理器初始化逻辑,支持独立的检索模型配置
* fix(benchmark): 移除数据路径默认值并设为必填参数
- 将LongMemEval评估脚本中的data_path参数改为必需参数
- 将HaluMem评估脚本中的data_path参数改为必需参数
- 删除了硬编码的默认文件路径配置
- 强制用户显式指定数据集文件路径以避免路径错误
* Update __init__.py
* Update __init__.py
* fix(benchmark): 修复ReMe评估中的模型配置和空值处理问题
- 移除了retrieve_memory调用中不需要的llm_config_name参数
- 修复了长字符串打印的换行格式问题
- 添加了eval_result为空时的初始化处理
- 在accuracy评估中加入了eval_model_name参数传递
* style(benchmark): 格式化模型名称打印输出
- 移除了多行字符串中的换行符和多余空格
- 将模型名称信息合并为单行连续显示
- 保持了原有的打印格式和信息完整性
* docs(readme): 更新文档添加实验结果表格
- 在英文版 README 中添加 🧪 Experiments 章节
- 添加 LoCoMo 和 HaluMem 两个基准测试的结果表格
- 在中文版 README_ZH 中添加 🧪 实验 章节
- 添加 LoCoMo 和 HaluMem 测试集的实验配置说明
- 添加完整的实验数据对比表格和评估协议说明
* docs(readme): 更新文档中的内存系统链接
- 为基于文件的记忆系统添加锚点链接
- 为基于向量库的记忆系统添加锚点链接
- 修复英文文档中的链接格式
- 修复中文文档中的链接格式和空行问题
* docs(readme): update experimental results section in documentation
- Remove outdated experimental data placeholder "Coming soon..."
- Add complete evaluation results for LoCoMo and HaluMem benchmarks
- Include detailed performance metrics tables for all memory methods
- Update experimental settings description with ReMe backbone details
- Align evaluation protocol information with LLM-as-a-Judge approach
- Maintain consistent formatting between English and Chinese documentation
* docs(benchmark): add quick start guides for halumem and longmemeval experiments
- Created HaluMem experiment quick start guide with ReMe integration setup
- Added detailed steps for installing ReMe environment using conda
- Included repository cloning instructions for HaluMem benchmark
- Provided complete command examples for running HaluMem experiments
- Created LongMeMEval quick start guide with data download procedures
- Added wget commands for downloading cleaned dataset files
- Included evaluation script instructions for computing experiment statistics
- Documented parameter configurations for different model types and batch sizes
* docs(longmemeval): update quickstart guide documentation
- Changed project name from Halumem to Longmemeval in title
- Updated description to reference Longmemeval experiments instead of Halumem
- Maintained existing ReMe integration instructions unchanged
* chore(logger): add test comment to logger configuration
- Added test comment in logger utility function
- Removed duplicate log handling by keeping the remove() call
* chore(logger): add test comment to logger configuration
- Added test comment in logger utility function
- Removed duplicate log handling by keeping the remove() call
* feat(core): add file logging capability to application
- Added log_to_file parameter to Application class constructor
- Integrated log_to_file option in logger initialization
- Updated ServiceContext to support file logging configuration
- Modified init_logger function to conditionally enable file logging
- Added log_to_file field to ServiceConfig schema
- Updated ReMe class to include file logging option
- Wrapped file logging setup in conditional check to prevent unnecessary operations
* docs(benchmark): update HaluMem quickstart guide with dataset download instructions
- Replace repository cloning with direct dataset download using curl
- Add commands to download HaluMem-Medium.jsonl and HaluMem-Long.jsonl files
- Include both official Hugging Face and mirror download sources
- Update data path reference from nested directory to local data folder
- Add dataset page link and mirror usage instructions for mainland China access
* feat(memory): add profile retrieval tool and refactor profile management
- Introduce RetrieveProfile tool for fetching specific user profiles
- Refactor ProfileHandler to support both filesystem and vector backends
- Add async methods to ProfileHandler with synchronous fallbacks
- Update PersonalRetriever to support two-stage profile and memory retrieval
- Enhance PersonalSummarizer with improved tool partitioning logic
- Add profile_backend, profile_store_name, and profile_max_capacity configuration options
- Replace direct ProfileHandler imports with get_profile_handler method
- Implement profile search functionality with dedicated prompts and workflows
- Add FileProfileBackend and VectorProfileBackend implementations
- Update base memory tool with new profile configuration parameters
* feat(profile): add custom profile collection name support
- Add profile_collection_name parameter to Application constructor
- Allow custom database collection name for vector profiles instead of default suffix
- Update profile vector store configuration logic to use custom collection name
- Modify _ensure_profile_vector_store_config to handle custom collection names
- Update docstring with detailed parameter descriptions for profile configuration options
* test(history): add single history id acceptance test for multiple mode
- Add test case to verify multiple-mode history lookup accepts a single history_id string
- Create FakeVectorStore stub with minimal implementation for ReadHistory tests
- Return requested history node from vector store mock
- Initialize ReadHistory tool with multiple mode enabled
- Add pylint disable comment for protected access to vector store property
* refactor(memory): update profile handler and vector tools with improved formatting and error handling
- Add module docstring to profiles/__init__.py
- Add pylint disable comments for no-name-in-module and missing-function-docstring
- Format long error message in ProfileHandler.sync_run method for better readability
- Reformat parameters in ProfileHandler.aadd method to separate lines
- Update model_copy call in reme.py to span multiple lines for better readability
- Format aadd_batch call in update_profile.py to span multiple lines
* feat(profiles): add profile management system with file and vector storage backends
- Add FileProfileBackend for filesystem-based profile persistence
- Add VectorProfileBackend for vector store-based profile management
- Create abstract BaseProfileBackend interface for profile operations
- Implement ProfileVectorHandler for vector-backed profile storage
- Add RetrieveProfile tool for semantic profile retrieval
- Update eval_reme.py to use user_message_s2 for retriever prompt
- Modify eval_reme.yaml to use {profiles} instead of {user_profile}
- Implement complete CRUD operations for profile management
- Add batch operations for efficient profile handling
- Include search functionality with semantic matching capabilities
- Add capacity limits and automatic cleanup for profile storage
* docs(profiles): add comprehensive docstrings for profile backend and handler methods
- Added documentation for get_all_sync, get_by_sync, delete_sync, delete_all_sync methods
- Documented add_sync and add_batch_sync functionality with deduping behavior
- Added docstrings for update_sync and search_sync operations
- Updated ProfileHandler.format_node method with proper documentation
- Refactored private _format_node to public format_node method
- Added comprehensive documentation for profile vector handler operations
- Documented _vector_profile_matches, _get_by_profile_id, _get_by_profile_key helper methods
- Added docstrings for retrieve_profile functionality and formatting methods
---
benchmark/locomo/eval_reme.py | 2 +-
benchmark/locomo/eval_reme.yaml | 2 +-
.../profiles/file_profile_backend.py | 234 +++++++++++++++++
.../vector_tools/profiles/profile_backend.py | 51 ++++
.../vector_tools/profiles/profile_handler.py | 7 +-
.../profiles/profile_vector_handler.py | 245 ++++++++++++++++++
.../vector_tools/profiles/retrieve_profile.py | 102 ++++++++
.../profiles/vector_profile_backend.py | 55 ++++
8 files changed, 693 insertions(+), 5 deletions(-)
create mode 100644 reme/memory/vector_tools/profiles/file_profile_backend.py
create mode 100644 reme/memory/vector_tools/profiles/profile_backend.py
create mode 100644 reme/memory/vector_tools/profiles/profile_vector_handler.py
create mode 100644 reme/memory/vector_tools/profiles/retrieve_profile.py
create mode 100644 reme/memory/vector_tools/profiles/vector_profile_backend.py
diff --git a/benchmark/locomo/eval_reme.py b/benchmark/locomo/eval_reme.py
index 82d91031..bb424aee 100644
--- a/benchmark/locomo/eval_reme.py
+++ b/benchmark/locomo/eval_reme.py
@@ -643,7 +643,7 @@ class LocomoEvaluator:
},
"personal_retriever": {
"prompt_dict": {
- "user_message": self.retriever_prompt,
+ "user_message_s2": self.retriever_prompt,
},
"params": {
"return_memory_nodes": True,
diff --git a/benchmark/locomo/eval_reme.yaml b/benchmark/locomo/eval_reme.yaml
index 113431b3..5228884a 100644
--- a/benchmark/locomo/eval_reme.yaml
+++ b/benchmark/locomo/eval_reme.yaml
@@ -132,7 +132,7 @@ user_message_retrieve: |
You are a Memory Retrieval Agent specialized in retrieving {memory_type} memories about {memory_target}.
## User Profile
- {user_profile}
+ {profiles}
## User Question
{context}
diff --git a/reme/memory/vector_tools/profiles/file_profile_backend.py b/reme/memory/vector_tools/profiles/file_profile_backend.py
new file mode 100644
index 00000000..e6226459
--- /dev/null
+++ b/reme/memory/vector_tools/profiles/file_profile_backend.py
@@ -0,0 +1,234 @@
+"""Filesystem-backed profile storage."""
+
+from pathlib import Path
+
+from loguru import logger
+
+from .profile_backend import BaseProfileBackend
+from ....core.enumeration import MemoryType
+from ....core.schema import MemoryNode
+from ....core.utils import CacheHandler, deduplicate_memories
+
+
+class FileProfileBackend(BaseProfileBackend):
+ """Persist user profiles in local JSONL cache files."""
+
+ def __init__(self, profile_path: str | Path, memory_target: str, max_capacity: int = 50):
+ super().__init__(memory_target=memory_target, max_capacity=max_capacity)
+ self.cache_key: str = self.memory_target.replace(" ", "_").lower()
+ self.cache_handler: CacheHandler = CacheHandler(profile_path)
+
+ def _load_nodes(self) -> list[MemoryNode]:
+ cached_data = self.cache_handler.load(self.cache_key, auto_clean=False)
+ if not cached_data:
+ return []
+ return [MemoryNode(**data) for data in cached_data]
+
+ def _save_nodes(self, nodes: list[MemoryNode], apply_limits: bool = True):
+ if apply_limits:
+ nodes = deduplicate_memories(nodes)
+
+ if len(nodes) > self.max_capacity:
+ sorted_nodes = sorted(nodes, key=lambda n: n.message_time)
+ removed_count = len(sorted_nodes) - self.max_capacity
+ nodes = sorted_nodes[removed_count:]
+ logger.info(
+ f"Capacity limit reached: removed {removed_count} oldest profiles "
+ f"(kept {len(nodes)}/{self.max_capacity})",
+ )
+
+ nodes_data = [node.model_dump(exclude_none=True) for node in nodes]
+ self.cache_handler.save(self.cache_key, nodes_data)
+ logger.info(f"Saved {len(nodes)} profiles to {self.cache_key}")
+
+ def get_all_sync(self) -> list[MemoryNode]:
+ """Load all profile nodes from cache, ordered by ``message_time``."""
+ nodes = self._load_nodes()
+ nodes.sort(key=lambda n: n.message_time)
+ return nodes
+
+ def get_by_sync(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ """Return the first node matching ``profile_id`` or ``profile_key``."""
+ if not profile_id and not profile_key:
+ raise ValueError("Must provide either profile_id or profile_key")
+
+ for node in self._load_nodes():
+ if profile_id and node.memory_id == profile_id:
+ return node
+ if profile_key and node.when_to_use == profile_key:
+ return node
+ return None
+
+ def delete_sync(self, profile_id: str | list[str]) -> bool | int:
+ """Remove one id, many ids, or none; returns bool, count, or 0/false if nothing removed."""
+ nodes = self._load_nodes()
+ original_count = len(nodes)
+
+ if isinstance(profile_id, list):
+ profile_ids_set = set(profile_id)
+ nodes = [n for n in nodes if n.memory_id not in profile_ids_set]
+ deleted_count = original_count - len(nodes)
+ if deleted_count == 0:
+ logger.warning(f"No profiles found to delete from {len(profile_id)} IDs")
+ return 0
+
+ self._save_nodes(nodes, apply_limits=False)
+ logger.info(f"Batch deleted {deleted_count} profiles")
+ return deleted_count
+
+ nodes = [n for n in nodes if n.memory_id != profile_id]
+ if len(nodes) == original_count:
+ logger.warning(f"Profile {profile_id} not found")
+ return False
+
+ self._save_nodes(nodes, apply_limits=False)
+ logger.info(f"Deleted profile {profile_id}")
+ return True
+
+ def delete_all_sync(self) -> int:
+ """Clear every cached profile for this target; returns how many were stored."""
+ nodes = self._load_nodes()
+ count = len(nodes)
+ self._save_nodes([], apply_limits=False)
+ logger.info(f"Deleted all {count} profiles")
+ return count
+
+ def add_sync(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
+ """Append a profile row, replacing any existing row with the same key."""
+ nodes = self._load_nodes()
+
+ new_node = MemoryNode(
+ memory_type=MemoryType.PERSONAL,
+ memory_target=self.memory_target,
+ when_to_use=profile_key,
+ content=profile_value,
+ message_time=message_time,
+ ref_memory_id=ref_memory_id,
+ )
+
+ original_count = len(nodes)
+ nodes = [n for n in nodes if n.when_to_use != profile_key]
+ if len(nodes) < original_count:
+ logger.info(f"Removed {original_count - len(nodes)} duplicate profile(s) with key: {profile_key}")
+
+ nodes.append(new_node)
+ self._save_nodes(nodes)
+ logger.info(f"Added profile: {profile_key}={profile_value}")
+ return new_node
+
+ def add_batch_sync(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ """Insert many profiles in one write, deduping by key against existing rows."""
+ if not profiles:
+ return []
+
+ nodes = self._load_nodes()
+ new_nodes = [
+ MemoryNode(
+ memory_type=MemoryType.PERSONAL,
+ memory_target=self.memory_target,
+ when_to_use=p.get("profile_key", ""),
+ content=p.get("profile_value", ""),
+ message_time=p.get("message_time", ""),
+ ref_memory_id=ref_memory_id,
+ )
+ for p in profiles
+ ]
+
+ new_keys = {n.when_to_use for n in new_nodes}
+ original_count = len(nodes)
+ nodes = [n for n in nodes if n.when_to_use not in new_keys]
+ if len(nodes) < original_count:
+ logger.info(f"Removed {original_count - len(nodes)} duplicate profile(s) with matching keys")
+
+ nodes.extend(new_nodes)
+ self._save_nodes(nodes)
+ logger.info(f"Batch added {len(new_nodes)} profiles")
+ return new_nodes
+
+ def update_sync(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ """Update fields for ``profile_id``; return ``None`` if that id is missing."""
+ nodes = self._load_nodes()
+ target_node = None
+ for node in nodes:
+ if node.memory_id == profile_id:
+ node.when_to_use = profile_key
+ node.content = profile_value
+ node.message_time = message_time
+ target_node = node
+ break
+
+ if target_node is None:
+ logger.warning(f"Profile {profile_id} not found")
+ return None
+
+ self._save_nodes(nodes, apply_limits=False)
+ logger.info(f"Updated profile {profile_id}: {profile_key}={profile_value}")
+ return target_node
+
+ def search_sync(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ """Simple substring/token match over key and content, best matches first."""
+ queries = [query] if isinstance(query, str) else query
+ query_terms = [q.strip().lower() for q in queries if q and q.strip()]
+ if not query_terms:
+ return []
+
+ scored_nodes = []
+ for node in self.get_all_sync():
+ profile_key = str(node.metadata.get("profile_key", node.when_to_use)).lower()
+ haystack = f"{profile_key}: {node.content}".lower()
+ score = 0
+ for term in query_terms:
+ if term in haystack:
+ score += len(term) + 10
+ else:
+ token_hits = sum(1 for token in term.split() if token and token in haystack)
+ score += token_hits
+
+ if score > 0:
+ node.score = float(score)
+ scored_nodes.append(node)
+
+ scored_nodes.sort(key=lambda n: (n.score, n.message_time), reverse=True)
+ return scored_nodes[:limit]
+
+ async def get_all(self) -> list[MemoryNode]:
+ return self.get_all_sync()
+
+ async def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ return self.get_by_sync(profile_id=profile_id, profile_key=profile_key)
+
+ async def delete(self, profile_id: str | list[str]) -> bool | int:
+ return self.delete_sync(profile_id)
+
+ async def delete_all(self) -> int:
+ return self.delete_all_sync()
+
+ async def add(
+ self,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ref_memory_id: str = "",
+ ) -> MemoryNode:
+ return self.add_sync(message_time, profile_key, profile_value, ref_memory_id)
+
+ async def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ return self.add_batch_sync(profiles, ref_memory_id)
+
+ async def update(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ return self.update_sync(profile_id, message_time, profile_key, profile_value)
+
+ async def search(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ return self.search_sync(query, limit)
diff --git a/reme/memory/vector_tools/profiles/profile_backend.py b/reme/memory/vector_tools/profiles/profile_backend.py
new file mode 100644
index 00000000..8ffcb9bc
--- /dev/null
+++ b/reme/memory/vector_tools/profiles/profile_backend.py
@@ -0,0 +1,51 @@
+"""Profile backend abstractions."""
+
+from abc import ABC, abstractmethod
+
+from ....core.schema import MemoryNode
+
+
+class BaseProfileBackend(ABC):
+ """Abstract interface for profile storage backends."""
+
+ def __init__(self, memory_target: str, max_capacity: int = 50):
+ self.memory_target = memory_target
+ self.max_capacity = max_capacity
+
+ @abstractmethod
+ async def get_all(self) -> list[MemoryNode]:
+ """Return all profile rows for the current user."""
+
+ @abstractmethod
+ async def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ """Return one profile row by id or key."""
+
+ @abstractmethod
+ async def delete(self, profile_id: str | list[str]) -> bool | int:
+ """Delete one or more profile rows."""
+
+ @abstractmethod
+ async def delete_all(self) -> int:
+ """Delete all profile rows for the current user."""
+
+ @abstractmethod
+ async def add(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
+ """Add a single profile row."""
+
+ @abstractmethod
+ async def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ """Add multiple profile rows."""
+
+ @abstractmethod
+ async def update(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ """Update one profile row."""
+
+ @abstractmethod
+ async def search(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ """Search profile rows relevant to the query."""
diff --git a/reme/memory/vector_tools/profiles/profile_handler.py b/reme/memory/vector_tools/profiles/profile_handler.py
index 0df3707a..145caa6e 100644
--- a/reme/memory/vector_tools/profiles/profile_handler.py
+++ b/reme/memory/vector_tools/profiles/profile_handler.py
@@ -115,7 +115,8 @@ class ProfileHandler:
return await self.backend.search(query=query, limit=limit)
@staticmethod
- def _format_node(node: MemoryNode, add_profile_id: bool = False, add_history_id: bool = False) -> str:
+ def format_node(node: MemoryNode, add_profile_id: bool = False, add_history_id: bool = False) -> str:
+ """Render a profile ``MemoryNode`` as a single-line string for tools/logs."""
parts = []
profile_key = str(node.metadata.get("profile_key", node.when_to_use))
@@ -134,7 +135,7 @@ class ProfileHandler:
async def aread_all(self, add_profile_id: bool = False, add_history_id: bool = False) -> str:
nodes = await self.aget_all()
- formatted_profiles = [self._format_node(node, add_profile_id, add_history_id) for node in nodes]
+ formatted_profiles = [self.format_node(node, add_profile_id, add_history_id) for node in nodes]
logger.info(f"Read {len(formatted_profiles)} profiles from {self.cache_key}")
return "\n".join(formatted_profiles).strip()
@@ -146,7 +147,7 @@ class ProfileHandler:
add_history_id: bool = False,
) -> tuple[list[MemoryNode], str]:
nodes = await self.asearch(query=query, limit=limit)
- formatted_profiles = [self._format_node(node, add_profile_id, add_history_id) for node in nodes]
+ formatted_profiles = [self.format_node(node, add_profile_id, add_history_id) for node in nodes]
return nodes, "\n".join(formatted_profiles).strip()
def delete(self, profile_id: str | list[str]) -> bool | int:
diff --git a/reme/memory/vector_tools/profiles/profile_vector_handler.py b/reme/memory/vector_tools/profiles/profile_vector_handler.py
new file mode 100644
index 00000000..f03e6a96
--- /dev/null
+++ b/reme/memory/vector_tools/profiles/profile_vector_handler.py
@@ -0,0 +1,245 @@
+"""Vector-backed handler for bounded user profiles."""
+
+import hashlib
+
+from loguru import logger
+
+from ....core import ServiceContext
+from ....core.enumeration import MemoryType
+from ....core.schema import MemoryNode
+from ....core.vector_store import BaseVectorStore
+
+
+class ProfileVectorHandler:
+ """Manage profile rows stored in a dedicated vector collection."""
+
+ PROFILE_KIND = "profile"
+
+ def __init__(
+ self,
+ memory_target: str,
+ service_context: ServiceContext,
+ vector_store_name: str = "profile",
+ max_capacity: int = 50,
+ ):
+ self.memory_target = memory_target
+ self.service_context = service_context
+ self.vector_store_name = vector_store_name
+ self.max_capacity = max_capacity
+ self.vector_store: BaseVectorStore = service_context.vector_stores[vector_store_name]
+
+ @staticmethod
+ def build_retrieval_text(profile_key: str, profile_value: str) -> str:
+ """Build the text that will be embedded for semantic profile retrieval."""
+ return f"{profile_key}: {profile_value}".strip(": ")
+
+ def build_profile_id(self, profile_key: str) -> str:
+ """Build a stable id from user and key."""
+ hash_obj = hashlib.sha256(f"{self.memory_target}\n{profile_key}".encode("utf-8"))
+ return hash_obj.hexdigest()[:16]
+
+ def _base_filters(self) -> dict:
+ """Filters shared by all profile rows in the vector collection."""
+ return {
+ "memory_type": MemoryType.IDENTITY.value,
+ "memory_target": self.memory_target,
+ "profile_kind": self.PROFILE_KIND,
+ }
+
+ def _build_profile_node(self, profile: dict, ref_memory_id: str = "") -> MemoryNode:
+ """Turn a profile dict into a ``MemoryNode`` for upsert into the vector store."""
+ profile_key = profile.get("profile_key", "").strip()
+ profile_value = profile.get("profile_value", "").strip()
+ message_time = profile.get("message_time", "")
+ ref_id = profile.get("ref_memory_id", ref_memory_id)
+ metadata = dict(profile.get("metadata", {}))
+ metadata.update(
+ {
+ "profile_key": profile_key,
+ "profile_kind": self.PROFILE_KIND,
+ "profile_backend": "vector",
+ },
+ )
+ return MemoryNode(
+ memory_id=self.build_profile_id(profile_key),
+ memory_type=MemoryType.IDENTITY,
+ memory_target=self.memory_target,
+ when_to_use=self.build_retrieval_text(profile_key, profile_value),
+ content=profile_value,
+ message_time=message_time,
+ ref_memory_id=ref_id,
+ metadata=metadata,
+ )
+
+ def _vector_profile_matches(self, memory_node: MemoryNode) -> bool:
+ """True if ``memory_node`` belongs to this handler's target and profile kind."""
+ if memory_node.memory_target != self.memory_target:
+ return False
+ if memory_node.memory_type is not MemoryType.IDENTITY:
+ return False
+ if memory_node.metadata.get("profile_kind") != self.PROFILE_KIND:
+ return False
+ return True
+
+ async def _get_by_profile_id(self, profile_id: str) -> MemoryNode | None:
+ """Load by vector id and validate filters."""
+ try:
+ vector_node = await self.vector_store.get(profile_id)
+ except KeyError:
+ logger.warning(f"Profile {profile_id} not found in vector store")
+ return None
+ if vector_node is None:
+ logger.warning(f"Profile {profile_id} not found in vector store")
+ return None
+ memory_node = MemoryNode.from_vector_node(vector_node)
+ if not self._vector_profile_matches(memory_node):
+ return None
+ return memory_node
+
+ async def _get_by_profile_key(self, profile_key: str) -> MemoryNode | None:
+ """Load the single row matching ``profile_key`` under base filters."""
+ filters = {**self._base_filters(), "profile_key": profile_key}
+ vector_nodes = await self.vector_store.list(filters=filters, limit=1)
+ if not vector_nodes:
+ return None
+ return MemoryNode.from_vector_node(vector_nodes[0])
+
+ async def get_all(self) -> list[MemoryNode]:
+ """List every profile row for this memory target, sorted by store."""
+ vector_nodes = await self.vector_store.list(
+ filters=self._base_filters(),
+ sort_key="message_time",
+ reverse=False,
+ )
+ return [MemoryNode.from_vector_node(node) for node in vector_nodes]
+
+ async def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ """Return one profile by stable id or by logical profile key."""
+ if not profile_id and not profile_key:
+ raise ValueError("Must provide either profile_id or profile_key")
+ if profile_id:
+ return await self._get_by_profile_id(profile_id)
+ return await self._get_by_profile_key(profile_key or "")
+
+ async def delete(self, profile_id: str | list[str]) -> bool | int:
+ """Delete one id, many ids, or report zero/false when nothing matched."""
+ if isinstance(profile_id, list):
+ profile_ids = list(dict.fromkeys(pid for pid in profile_id if pid))
+ if not profile_ids:
+ return 0
+ existing_nodes = []
+ for pid in profile_ids:
+ node = await self.get_by(profile_id=pid)
+ if node is not None:
+ existing_nodes.append(node)
+ if not existing_nodes:
+ return 0
+ await self.vector_store.delete([node.memory_id for node in existing_nodes])
+ return len(existing_nodes)
+
+ existing_node = await self.get_by(profile_id=profile_id)
+ if existing_node is None:
+ return False
+ await self.vector_store.delete(existing_node.memory_id)
+ return True
+
+ async def delete_all(self) -> int:
+ """Remove all profile vectors for this target; returns how many were deleted."""
+ nodes = await self.get_all()
+ if not nodes:
+ return 0
+ await self.vector_store.delete([node.memory_id for node in nodes])
+ return len(nodes)
+
+ async def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ """Upsert many profiles at once (last dict wins per key), then enforce capacity."""
+ if not profiles:
+ return []
+
+ deduped_profiles: dict[str, dict] = {}
+ for profile in profiles:
+ profile_key = profile.get("profile_key", "").strip()
+ if not profile_key:
+ continue
+ deduped_profiles[profile_key] = profile
+
+ new_nodes = [
+ self._build_profile_node(profile, ref_memory_id=ref_memory_id) for profile in deduped_profiles.values()
+ ]
+ if not new_nodes:
+ return []
+
+ await self.vector_store.delete([node.memory_id for node in new_nodes])
+ await self.vector_store.insert([node.to_vector_node() for node in new_nodes])
+ await self.enforce_capacity()
+ return new_nodes
+
+ async def add(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
+ """Insert or replace a single profile row."""
+ nodes = await self.add_batch(
+ [
+ {
+ "message_time": message_time,
+ "profile_key": profile_key,
+ "profile_value": profile_value,
+ },
+ ],
+ ref_memory_id=ref_memory_id,
+ )
+ return nodes[0]
+
+ async def update(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ """Replace content and key for ``profile_id``; return ``None`` if missing."""
+ existing_node = await self.get_by(profile_id=profile_id)
+ if existing_node is None:
+ return None
+
+ new_node = self._build_profile_node(
+ {
+ "message_time": message_time,
+ "profile_key": profile_key,
+ "profile_value": profile_value,
+ "ref_memory_id": existing_node.ref_memory_id,
+ "metadata": existing_node.metadata,
+ },
+ )
+
+ if existing_node.memory_id != new_node.memory_id:
+ await self.vector_store.delete(existing_node.memory_id)
+ else:
+ await self.vector_store.delete(new_node.memory_id)
+
+ await self.vector_store.insert(new_node.to_vector_node())
+ await self.enforce_capacity()
+ return new_node
+
+ async def search(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ """Semantic search with de-duplication across multiple query strings."""
+ queries = [query] if isinstance(query, str) else query
+ seen_nodes: dict[str, MemoryNode] = {}
+ for item in queries:
+ if not item or not item.strip():
+ continue
+ vector_nodes = await self.vector_store.search(item, limit=limit, filters=self._base_filters())
+ for vector_node in vector_nodes:
+ memory_node = MemoryNode.from_vector_node(vector_node)
+ seen_nodes[memory_node.memory_id] = memory_node
+ nodes = list(seen_nodes.values())
+ nodes.sort(key=lambda node: (node.score, node.message_time), reverse=True)
+ return nodes[:limit]
+
+ async def enforce_capacity(self):
+ """Drop oldest rows when count exceeds ``max_capacity``."""
+ nodes = await self.get_all()
+ overflow = len(nodes) - self.max_capacity
+ if overflow <= 0:
+ return
+
+ to_delete = [node.memory_id for node in nodes[:overflow]]
+ await self.vector_store.delete(to_delete)
diff --git a/reme/memory/vector_tools/profiles/retrieve_profile.py b/reme/memory/vector_tools/profiles/retrieve_profile.py
new file mode 100644
index 00000000..38337bd8
--- /dev/null
+++ b/reme/memory/vector_tools/profiles/retrieve_profile.py
@@ -0,0 +1,102 @@
+"""Retrieve relevant profile rows."""
+
+from loguru import logger
+
+from .profile_handler import ProfileHandler
+from ..base_memory_tool import BaseMemoryTool
+from ....core.schema import MemoryNode, ToolCall
+
+
+class RetrieveProfile(BaseMemoryTool):
+ """Tool to retrieve relevant profiles using the configured backend."""
+
+ def __init__(self, top_k: int = 5, enable_memory_target: bool = False, **kwargs):
+ super().__init__(**kwargs)
+ self.top_k = top_k
+ self.enable_memory_target = enable_memory_target
+
+ def _build_query_parameters(self) -> dict:
+ properties = {
+ "query": {
+ "type": "string",
+ "description": "query",
+ },
+ }
+ required = ["query"]
+ if self.enable_memory_target:
+ properties["memory_target"] = {
+ "type": "string",
+ "description": "memory_target",
+ }
+ required.append("memory_target")
+ return {
+ "type": "object",
+ "properties": properties,
+ "required": required,
+ }
+
+ def _build_tool_call(self) -> ToolCall:
+ return ToolCall(
+ **{
+ "description": "Retrieve relevant user profiles using semantic matching.",
+ "parameters": self._build_query_parameters(),
+ },
+ )
+
+ def _build_multiple_tool_call(self) -> ToolCall:
+ return ToolCall(
+ **{
+ "description": "Retrieve relevant user profiles using semantic matching.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query_items": {
+ "type": "array",
+ "description": "List of query items.",
+ "items": self._build_query_parameters(),
+ },
+ },
+ "required": ["query_items"],
+ },
+ },
+ )
+
+ async def execute(self):
+ if self.enable_multiple:
+ query_items = self.context.get("query_items", [])
+ else:
+ query_items = [self.context]
+
+ queries_by_target: dict[str, list[str]] = {}
+ for item in query_items:
+ target = item["memory_target"] if self.enable_memory_target else self.memory_target
+ queries_by_target.setdefault(target, []).append(item["query"])
+
+ profile_nodes: list[MemoryNode] = []
+ for target, queries in queries_by_target.items():
+ profile_handler = self.get_profile_handler(target)
+ nodes, _ = await profile_handler.aretrieve(
+ query=queries,
+ limit=self.top_k,
+ add_profile_id=True,
+ add_history_id=True,
+ )
+ profile_nodes.extend(nodes)
+
+ seen_ids = {node.memory_id: node for node in self.retrieved_nodes if node.memory_id}
+ new_nodes = []
+ for node in profile_nodes:
+ if node.memory_id not in seen_ids:
+ seen_ids[node.memory_id] = node
+ new_nodes.append(node)
+ self.retrieved_nodes.extend(new_nodes)
+
+ if not new_nodes:
+ output = "No new profiles found."
+ else:
+ output = "\n".join(
+ [ProfileHandler.format_node(node, add_profile_id=True, add_history_id=True) for node in new_nodes],
+ )
+
+ logger.info(f"Retrieved {len(profile_nodes)} profiles, {len(new_nodes)} new after deduplication")
+ return output
diff --git a/reme/memory/vector_tools/profiles/vector_profile_backend.py b/reme/memory/vector_tools/profiles/vector_profile_backend.py
new file mode 100644
index 00000000..3147cc92
--- /dev/null
+++ b/reme/memory/vector_tools/profiles/vector_profile_backend.py
@@ -0,0 +1,55 @@
+"""Vector-backed profile storage."""
+
+from .profile_backend import BaseProfileBackend
+from .profile_vector_handler import ProfileVectorHandler
+from ....core import ServiceContext
+from ....core.schema import MemoryNode
+
+
+class VectorProfileBackend(BaseProfileBackend):
+ """Persist user profiles in a dedicated vector store."""
+
+ def __init__(
+ self,
+ memory_target: str,
+ service_context: ServiceContext,
+ vector_store_name: str = "profile",
+ max_capacity: int = 50,
+ ):
+ super().__init__(memory_target=memory_target, max_capacity=max_capacity)
+ self.handler = ProfileVectorHandler(
+ memory_target=memory_target,
+ service_context=service_context,
+ vector_store_name=vector_store_name,
+ max_capacity=max_capacity,
+ )
+
+ async def get_all(self) -> list[MemoryNode]:
+ return await self.handler.get_all()
+
+ async def get_by(self, *, profile_id: str | None = None, profile_key: str | None = None) -> MemoryNode | None:
+ return await self.handler.get_by(profile_id=profile_id, profile_key=profile_key)
+
+ async def delete(self, profile_id: str | list[str]) -> bool | int:
+ return await self.handler.delete(profile_id)
+
+ async def delete_all(self) -> int:
+ return await self.handler.delete_all()
+
+ async def add(self, message_time: str, profile_key: str, profile_value: str, ref_memory_id: str = "") -> MemoryNode:
+ return await self.handler.add(message_time, profile_key, profile_value, ref_memory_id)
+
+ async def add_batch(self, profiles: list[dict], ref_memory_id: str = "") -> list[MemoryNode]:
+ return await self.handler.add_batch(profiles, ref_memory_id)
+
+ async def update(
+ self,
+ profile_id: str,
+ message_time: str,
+ profile_key: str,
+ profile_value: str,
+ ) -> MemoryNode | None:
+ return await self.handler.update(profile_id, message_time, profile_key, profile_value)
+
+ async def search(self, query: str | list[str], limit: int = 5) -> list[MemoryNode]:
+ return await self.handler.search(query, limit)
From f42cf60706611afdfa57dd3c09bca106c93c368f Mon Sep 17 00:00:00 2001
From: lichen2015
Date: Fri, 8 May 2026 17:12:11 +0800
Subject: [PATCH 05/16] add zvec vector/file store (#218)
---
README.md | 2 +-
README_ZH.md | 2 +-
docs/vector_store_api_guide.md | 33 +-
reme/core/file_store/__init__.py | 3 +
reme/core/file_store/zvec_file_store.py | 573 +++++++++++++
reme/core/vector_store/__init__.py | 3 +
reme/core/vector_store/zvec_vector_store.py | 809 +++++++++++++++++
tests/test_file_store.py | 45 +-
tests/test_vector_store.py | 39 +-
tests/test_zvec_vector_store.py | 906 ++++++++++++++++++++
tests/vector/test_reme_vector.py | 2 +-
11 files changed, 2409 insertions(+), 8 deletions(-)
create mode 100644 reme/core/file_store/zvec_file_store.py
create mode 100644 reme/core/vector_store/zvec_vector_store.py
create mode 100644 tests/test_zvec_vector_store.py
diff --git a/README.md b/README.md
index f2f413c0..b4bb48c4 100644
--- a/README.md
+++ b/README.md
@@ -506,7 +506,7 @@ async def main():
"dimensions": 1024,
},
default_vector_store_config={
- "backend": "local", # Supports local/chroma/qdrant/elasticsearch/obvec
+ "backend": "local", # Supports local/chroma/qdrant/elasticsearch/obvec/zvec
},
)
await reme.start()
diff --git a/README_ZH.md b/README_ZH.md
index 7e6ccd58..210a11ce 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -486,7 +486,7 @@ async def main():
"dimensions": 1024,
},
default_vector_store_config={
- "backend": "local", # 支持 local/chroma/qdrant/elasticsearch/obvec
+ "backend": "local", # 支持 local/chroma/qdrant/elasticsearch/obvec/zvec
},
)
await reme.start()
diff --git a/docs/vector_store_api_guide.md b/docs/vector_store_api_guide.md
index b0a06eab..89881ef2 100644
--- a/docs/vector_store_api_guide.md
+++ b/docs/vector_store_api_guide.md
@@ -34,6 +34,7 @@ FlowLLM provides multiple Vector Store implementations tailored to different use
- **ChromaVectorStore** ([source code](https://github.com/flowllm-ai/flowllm/blob/main/flowllm/core/vector_store/chroma_vector_store.py)): Based on ChromaDB, providing persistent storage and metadata filtering capabilities.
- **EsVectorStore** ([source code](https://github.com/flowllm-ai/flowllm/blob/main/flowllm/core/vector_store/es_vector_store.py)): Built on Elasticsearch, enabling powerful combined full-text and vector search functionalities.
- **ObVecVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/obvec_vector_store.py)): Uses [pyobvector](https://pypi.org/project/pyobvector/) against **OceanBase** or **seekdb** (MySQL-compatible wire protocol). Suitable when you already run OceanBase/seekdb or need a SQL-native vector table with HNSW-style ANN search and JSON metadata filters.
+- **ZvecVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/zvec_vector_store.py)): Built on zvec, a high-performance local vector database with strong-schema support and HNSW indexing. Suitable for single-machine deployments requiring fast vector search.
All Vector Store implementations inherit from **BaseVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/base_vector_store.py)) in ReMe, ensuring a consistent interface specification.
@@ -130,6 +131,11 @@ docker run -d --name reme_seekdb -p 2881:2881 -e ROOT_PASSWORD= python tests/test_vector_store.py --obvec
```
+### ZvecVectorStore Configuration
+
+- **db_path**: Local storage path for persistent mode (required).
+- **dimension**: Dimensionality of the embedding vectors (default: `1024`).
+- **distance**: Distance metric — supports `cosine`, `l2`, `ip` (default: `cosine`).
## Configuration File Examples
@@ -151,7 +157,7 @@ vector_store.default.params.=
### Configuration Field Descriptions
-- **`backend`** (required): Vector store backend type. Options: `local`, `memory`, `chroma`, `qdrant`, `elasticsearch`, `obvec`.
+- **`backend`** (required): Vector store backend type. Options: `local`, `memory`, `chroma`, `qdrant`, `elasticsearch`, `obvec`, `zvec`.
- **`embedding_model`** (required): Name of the embedding model configuration, referencing the `embedding_model` section.
- **`params`** (optional): Dictionary of backend-specific parameters passed to the vector store constructor.
@@ -347,6 +353,30 @@ vector_stores.default.password=your-root-password
ReMe service YAML uses the key `vector_stores` (plural); CLI overrides use the same nested paths.
+#### 7. ZvecVectorStore Configuration
+
+Persistent local storage based on zvec with HNSW indexing and strong-schema support.
+
+**Implementation**: [`reme/core/vector_store/zvec_vector_store.py`](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/zvec_vector_store.py)
+
+```yaml
+vector_store:
+ default:
+ backend: zvec
+ embedding_model: default
+ params:
+ db_path: "./zvec_vector_store" # Local storage path (required)
+ dimension: 1024 # Vector dimension (optional; default: 1024)
+ distance: "cosine" # Distance metric (optional; default: cosine; options: cosine, l2, ip)
+```
+
+```shell
+vector_store.default.backend=zvec
+vector_store.default.params.db_path=./zvec_vector_store
+vector_store.default.params.dimension=1024
+vector_store.default.params.distance=cosine
+```
+
### Complete Configuration Example
Below is a complete `default.yaml` example including both embedding model and vector store configurations:
@@ -405,6 +435,7 @@ Two types of metadata filtering are supported:
- **Development & Testing**: Use MemoryVectorStore or LocalVectorStore—no additional services required.
- **Small-Scale Applications**: Use LocalVectorStore or ChromaVectorStore for simplicity and ease of use.
- **Production Environments**: Use QdrantVectorStore, EsVectorStore, or ObVecVectorStore (OceanBase/seekdb) for high performance and scalability, depending on your existing infrastructure.
+- **High-Performance Local Search**: Use ZvecVectorStore for single-machine deployments requiring fast HNSW-based vector search with local persistence.
- **Hybrid Search**: Use EsVectorStore to combine vector search with full-text search capabilities.
- **OceanBase / seekdb**: Use ObVecVectorStore when you standardize on pyobvector and SQL-accessible vector tables.
diff --git a/reme/core/file_store/__init__.py b/reme/core/file_store/__init__.py
index 1358df52..a8457406 100644
--- a/reme/core/file_store/__init__.py
+++ b/reme/core/file_store/__init__.py
@@ -9,6 +9,7 @@ from .base_file_store import BaseFileStore
from .chroma_file_store import ChromaFileStore
from .local_file_store import LocalFileStore
from .sqlite_file_store import SqliteFileStore
+from .zvec_file_store import ZvecFileStore
from ..registry_factory import R
__all__ = [
@@ -16,8 +17,10 @@ __all__ = [
"ChromaFileStore",
"LocalFileStore",
"SqliteFileStore",
+ "ZvecFileStore",
]
R.file_stores.register("sqlite")(SqliteFileStore)
R.file_stores.register("chroma")(ChromaFileStore)
R.file_stores.register("local")(LocalFileStore)
+R.file_stores.register("zvec")(ZvecFileStore)
diff --git a/reme/core/file_store/zvec_file_store.py b/reme/core/file_store/zvec_file_store.py
new file mode 100644
index 00000000..3d162819
--- /dev/null
+++ b/reme/core/file_store/zvec_file_store.py
@@ -0,0 +1,573 @@
+"""Zvec storage backend for file store."""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Any
+
+from .base_file_store import BaseFileStore
+from ..enumeration import MemorySource
+from ..schema import FileMetadata, MemoryChunk, MemorySearchResult
+from ..utils import get_logger
+
+logger = get_logger()
+
+_ZVEC_IMPORT_ERROR: Exception | None = None
+
+try:
+ import zvec # type: ignore[import-untyped]
+ from zvec import (
+ CollectionOption,
+ CollectionSchema,
+ DataType,
+ Doc,
+ FieldSchema,
+ HnswIndexParam,
+ InvertIndexParam,
+ VectorQuery,
+ VectorSchema,
+ )
+ from zvec.typing import MetricType
+except Exception as e:
+ _ZVEC_IMPORT_ERROR = e
+ zvec = None # type: ignore[assignment]
+
+
+# zvec max topk (will be lifted to 100,000 in zvec v0.3.2+)
+_ZVEC_MAX_TOPK = 1024
+
+# Default vector field name
+_DEFAULT_VECTOR_FIELD = "embedding"
+
+
+def _escape(value: str) -> str:
+ """Escape a string value for zvec filter expressions."""
+ return value.replace("'", "\\'")
+
+
+def _build_file_store_schema(name: str, dimension: int) -> CollectionSchema:
+ """Build a zvec CollectionSchema for file store chunks."""
+ return CollectionSchema(
+ name=name,
+ fields=[
+ FieldSchema("content", DataType.STRING, nullable=True, index_param=InvertIndexParam()),
+ FieldSchema("path", DataType.STRING, nullable=True, index_param=InvertIndexParam()),
+ FieldSchema("source", DataType.STRING, nullable=True, index_param=InvertIndexParam()),
+ FieldSchema("start_line", DataType.INT64, nullable=True),
+ FieldSchema("end_line", DataType.INT64, nullable=True),
+ FieldSchema("hash", DataType.STRING, nullable=True),
+ FieldSchema("updated_at", DataType.INT64, nullable=True),
+ FieldSchema("file_metadata", DataType.STRING, nullable=True),
+ ],
+ vectors=[
+ VectorSchema(
+ name=_DEFAULT_VECTOR_FIELD,
+ data_type=DataType.VECTOR_FP32,
+ dimension=dimension,
+ index_param=HnswIndexParam(metric_type=MetricType.COSINE),
+ ),
+ ],
+ )
+
+
+def _chunk_to_doc(chunk: MemoryChunk, file_meta_json: str = "{}") -> Doc:
+ """Convert a MemoryChunk to a zvec Doc."""
+ fields: dict[str, Any] = {
+ "content": chunk.text,
+ "path": chunk.path,
+ "source": chunk.source.value if chunk.source else "",
+ "start_line": chunk.start_line,
+ "end_line": chunk.end_line,
+ "hash": chunk.hash,
+ "updated_at": int(time.time() * 1000),
+ "file_metadata": file_meta_json,
+ }
+ vectors: dict[str, Any] = {}
+ if chunk.embedding is not None:
+ vectors[_DEFAULT_VECTOR_FIELD] = chunk.embedding
+ return Doc(id=chunk.id, fields=fields, vectors=vectors)
+
+
+def _doc_to_chunk(doc: Doc) -> MemoryChunk:
+ """Convert a zvec Doc to a MemoryChunk."""
+ raw_vector = doc.vector(_DEFAULT_VECTOR_FIELD)
+ vector = raw_vector if isinstance(raw_vector, list) and len(raw_vector) > 0 else None
+ return MemoryChunk(
+ id=str(doc.id),
+ path=str(doc.field("path") or ""),
+ source=MemorySource(str(doc.field("source") or "")),
+ start_line=int(doc.field("start_line") or 0),
+ end_line=int(doc.field("end_line") or 0),
+ text=str(doc.field("content") or ""),
+ hash=str(doc.field("hash") or ""),
+ embedding=vector,
+ )
+
+
+def _build_source_filter(sources: list[MemorySource] | None) -> str | None:
+ """Build a zvec filter expression for source filtering."""
+ if not sources:
+ return None
+ if len(sources) == 1:
+ return f"source='{_escape(sources[0].value)}'"
+ vals = ", ".join(f"'{_escape(s.value)}'" for s in sources)
+ return f"source IN ({vals})"
+
+
+class ZvecFileStore(BaseFileStore):
+ """Zvec file storage with vector and keyword search.
+
+ Provides zvec-backed persistent storage with:
+ - Vector similarity search (native zvec HNSW)
+ - Keyword search (Python substring matching on fetched results)
+ - Hybrid search (weighted fusion of vector and keyword results)
+
+ Note:
+ Keyword search operates on chunks fetched from zvec, which is subject
+ to the topk limit (1024 in zvec < v0.3.2, 100,000 in v0.3.2+).
+ For collections with more chunks than the topk limit, keyword search
+ may not scan all documents.
+ """
+
+ def __init__(
+ self,
+ store_name: str,
+ db_path: str | Path,
+ embedding_model: Any | None = None,
+ vector_enabled: bool = False,
+ fts_enabled: bool = True,
+ dimension: int = 1024,
+ **kwargs: Any,
+ ):
+ if _ZVEC_IMPORT_ERROR is not None:
+ raise ImportError(
+ "Zvec requires extra dependencies. Install with `pip install zvec`",
+ ) from _ZVEC_IMPORT_ERROR
+
+ super().__init__(
+ store_name=store_name,
+ db_path=db_path,
+ embedding_model=embedding_model,
+ vector_enabled=vector_enabled,
+ fts_enabled=fts_enabled,
+ **kwargs,
+ )
+
+ self.dimension = dimension
+ self._collection = None
+ self._initialized = False
+ self._metadata_file: Path = self.db_path / f"{store_name}_file_metadata.json"
+ self._metadata_cache: dict[str, dict[str, FileMetadata]] = {}
+
+ @property
+ def collection_name(self) -> str:
+ """Get the name of the zvec collection for this store."""
+ return f"chunks_{self.store_name}"
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ async def start(self) -> None:
+ """Initialize zvec engine and open the collection."""
+ if not self._initialized:
+ try:
+ zvec.init()
+ except RuntimeError:
+ pass
+ self._initialized = True
+
+ self.db_path.mkdir(parents=True, exist_ok=True)
+ collection_path = str(self.db_path / self.collection_name)
+ option = CollectionOption(read_only=False, enable_mmap=True)
+
+ try:
+ self._collection = zvec.open(collection_path, option)
+ logger.info(f"Opened existing zvec file store collection: {collection_path}")
+ except Exception:
+ schema = _build_file_store_schema(self.collection_name, self.dimension)
+ self._collection = zvec.create_and_open(
+ path=collection_path,
+ schema=schema,
+ option=option,
+ )
+ logger.info(f"Created new zvec file store collection: {collection_path}")
+
+ self._metadata_cache = await self._load_metadata()
+
+ async def close(self) -> None:
+ """Close zvec collection and persist metadata."""
+ if self._metadata_cache:
+ await self._save_metadata(self._metadata_cache)
+
+ if self._collection is not None:
+ try:
+ self._collection.flush()
+ except Exception as e:
+ logger.warning(f"Failed to flush collection on close: {e}")
+ self._collection = None
+
+ # ------------------------------------------------------------------
+ # Metadata management
+ # ------------------------------------------------------------------
+
+ async def _load_metadata(self) -> dict[str, dict[str, FileMetadata]]:
+ """Load file metadata from JSON file."""
+ if not self._metadata_file.exists():
+ return {}
+ try:
+ data = json.loads(self._metadata_file.read_text(encoding="utf-8"))
+ result: dict[str, dict[str, FileMetadata]] = {}
+ for source, files in data.items():
+ result[source] = {}
+ for path, meta in files.items():
+ result[source][path] = FileMetadata(**meta)
+ return result
+ except Exception as e:
+ logger.warning(f"Failed to load metadata from {self._metadata_file}: {e}")
+ return {}
+
+ async def _save_metadata(self, metadata: dict[str, dict[str, FileMetadata]]) -> None:
+ """Save file metadata to JSON file."""
+ try:
+ out: dict[str, dict[str, dict]] = {}
+ for source, files in metadata.items():
+ out[source] = {}
+ for path, meta in files.items():
+ out[source][path] = {
+ "path": meta.path,
+ "hash": meta.hash,
+ "mtime_ms": meta.mtime_ms,
+ "size": meta.size,
+ "chunk_count": meta.chunk_count,
+ }
+ self._metadata_file.write_text(
+ json.dumps(out, indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ except Exception as e:
+ logger.error(f"Failed to save metadata to {self._metadata_file}: {e}")
+
+ # ------------------------------------------------------------------
+ # CRUD operations
+ # ------------------------------------------------------------------
+
+ async def upsert_file(
+ self,
+ file_meta: FileMetadata,
+ source: MemorySource,
+ chunks: list[MemoryChunk],
+ ) -> None:
+ """Insert or update a file and its chunks."""
+ if not chunks:
+ return
+
+ # Delete existing chunks for this file first
+ await self.delete_file(file_meta.path, source)
+
+ # Generate embeddings
+ chunks = await self.get_chunk_embeddings(chunks)
+
+ file_meta_json = json.dumps(
+ {
+ "path": file_meta.path,
+ "hash": file_meta.hash,
+ "mtime_ms": file_meta.mtime_ms,
+ "size": file_meta.size,
+ "chunk_count": len(chunks),
+ },
+ ensure_ascii=False,
+ )
+
+ docs = [_chunk_to_doc(c, file_meta_json) for c in chunks]
+ self._collection.insert(docs)
+
+ # Update metadata cache
+ if source.value not in self._metadata_cache:
+ self._metadata_cache[source.value] = {}
+ self._metadata_cache[source.value][file_meta.path] = FileMetadata(
+ hash=file_meta.hash,
+ mtime_ms=file_meta.mtime_ms,
+ size=file_meta.size,
+ path=file_meta.path,
+ chunk_count=len(chunks),
+ )
+
+ async def delete_file(self, path: str, source: MemorySource) -> None:
+ """Delete a file and all its chunks."""
+ filter_expr = f"path='{_escape(path)}' AND source='{_escape(source.value)}'"
+ results = self._collection.query(topk=_ZVEC_MAX_TOPK, filter=filter_expr, include_vector=False)
+
+ ids_to_delete = [doc.id for doc in results]
+ if ids_to_delete:
+ self._collection.delete(ids_to_delete)
+
+ if source.value in self._metadata_cache:
+ self._metadata_cache[source.value].pop(path, None)
+
+ async def delete_file_chunks(self, path: str, chunk_ids: list[str]) -> None:
+ """Delete specific chunks for a file."""
+ if not chunk_ids:
+ return
+ self._collection.delete(chunk_ids)
+
+ async def upsert_chunks(
+ self,
+ chunks: list[MemoryChunk],
+ source: MemorySource,
+ ) -> None:
+ """Insert or update specific chunks."""
+ if not chunks:
+ return
+
+ chunks = await self.get_chunk_embeddings(chunks)
+ docs = [_chunk_to_doc(c) for c in chunks]
+ self._collection.upsert(docs)
+
+ # ------------------------------------------------------------------
+ # Listing and metadata
+ # ------------------------------------------------------------------
+
+ async def list_files(self, source: MemorySource) -> list[str]:
+ """List all indexed files for a source."""
+ if source.value not in self._metadata_cache:
+ return []
+ return list(self._metadata_cache[source.value].keys())
+
+ async def get_file_metadata(
+ self,
+ path: str,
+ source: MemorySource,
+ ) -> FileMetadata | None:
+ """Get file metadata."""
+ if source.value not in self._metadata_cache:
+ return None
+ return self._metadata_cache[source.value].get(path)
+
+ async def update_file_metadata(self, file_meta: FileMetadata, source: MemorySource) -> None:
+ """Update file metadata without affecting chunks."""
+ if source.value not in self._metadata_cache:
+ self._metadata_cache[source.value] = {}
+ self._metadata_cache[source.value][file_meta.path] = FileMetadata(
+ hash=file_meta.hash,
+ mtime_ms=file_meta.mtime_ms,
+ size=file_meta.size,
+ path=file_meta.path,
+ chunk_count=file_meta.chunk_count,
+ )
+
+ async def get_file_chunks(
+ self,
+ path: str,
+ source: MemorySource,
+ ) -> list[MemoryChunk]:
+ """Get all chunks for a file."""
+ filter_expr = f"path='{_escape(path)}' AND source='{_escape(source.value)}'"
+ results = self._collection.query(
+ topk=_ZVEC_MAX_TOPK,
+ filter=filter_expr,
+ include_vector=True,
+ )
+ chunks = [_doc_to_chunk(doc) for doc in results]
+ chunks.sort(key=lambda c: c.start_line)
+ return chunks
+
+ # ------------------------------------------------------------------
+ # Search
+ # ------------------------------------------------------------------
+
+ async def vector_search(
+ self,
+ query: str,
+ limit: int,
+ sources: list[MemorySource] | None = None,
+ ) -> list[MemorySearchResult]:
+ """Perform vector similarity search."""
+ if not self.vector_enabled or not query:
+ return []
+
+ query_embedding = await self.get_embedding(query)
+ if not query_embedding:
+ return []
+
+ filter_expr = _build_source_filter(sources)
+ vq = VectorQuery(field_name=_DEFAULT_VECTOR_FIELD, vector=query_embedding)
+
+ try:
+ results = self._collection.query(
+ vectors=vq,
+ topk=min(limit, _ZVEC_MAX_TOPK),
+ filter=filter_expr,
+ include_vector=False,
+ )
+ except Exception as e:
+ logger.error(f"Vector search failed: {e}")
+ return []
+
+ search_results = []
+ for doc in results:
+ score = doc.score if doc.score is not None else 0.0
+ # zvec cosine score might need normalization depending on version
+ search_results.append(
+ MemorySearchResult(
+ path=str(doc.field("path") or ""),
+ start_line=int(doc.field("start_line") or 0),
+ end_line=int(doc.field("end_line") or 0),
+ score=score,
+ snippet=str(doc.field("content") or ""),
+ source=MemorySource(str(doc.field("source") or "")),
+ raw_metric=score,
+ ),
+ )
+
+ search_results.sort(key=lambda r: r.score, reverse=True)
+ return search_results[:limit]
+
+ async def keyword_search(
+ self,
+ query: str,
+ limit: int,
+ sources: list[MemorySource] | None = None,
+ ) -> list[MemorySearchResult]:
+ """Perform keyword search via Python substring matching.
+
+ Fetches chunks from zvec (subject to topk limit) then matches
+ keywords in Python. For collections larger than the topk limit,
+ not all documents are scanned.
+ """
+ if not self.fts_enabled or not query:
+ return []
+
+ words = query.split()
+ if not words:
+ return []
+
+ # Fetch candidate chunks from zvec
+ filter_expr = _build_source_filter(sources)
+ results = self._collection.query(
+ topk=_ZVEC_MAX_TOPK,
+ filter=filter_expr,
+ include_vector=False,
+ )
+
+ query_lower = query.lower()
+ words_lower = [w.lower() for w in words]
+ n_words = len(words)
+
+ search_results = []
+ for doc in results:
+ text = str(doc.field("content") or "")
+ text_lower = text.lower()
+ match_count = sum(1 for w in words_lower if w in text_lower)
+ if match_count == 0:
+ continue
+
+ base_score = match_count / n_words
+ phrase_bonus = 0.2 if n_words > 1 and query_lower in text_lower else 0.0
+ score = min(1.0, base_score + phrase_bonus)
+
+ search_results.append(
+ MemorySearchResult(
+ path=str(doc.field("path") or ""),
+ start_line=int(doc.field("start_line") or 0),
+ end_line=int(doc.field("end_line") or 0),
+ score=score,
+ snippet=text,
+ source=MemorySource(str(doc.field("source") or "")),
+ ),
+ )
+
+ search_results.sort(key=lambda r: r.score, reverse=True)
+ return search_results[:limit]
+
+ async def hybrid_search(
+ self,
+ query: str,
+ limit: int,
+ sources: list[MemorySource] | None = None,
+ vector_weight: float = 0.7,
+ candidate_multiplier: float = 3.0,
+ ) -> list[MemorySearchResult]:
+ """Perform hybrid search combining vector and keyword search."""
+ assert 0.0 <= vector_weight <= 1.0, f"vector_weight must be between 0 and 1, got {vector_weight}"
+
+ candidates = min(200, max(1, int(limit * candidate_multiplier)))
+ text_weight = 1.0 - vector_weight
+
+ if self.vector_enabled and self.fts_enabled:
+ keyword_results = await self.keyword_search(query, candidates, sources)
+ vector_results = await self.vector_search(query, candidates, sources)
+
+ if not keyword_results:
+ return vector_results[:limit]
+ elif not vector_results:
+ return keyword_results[:limit]
+ else:
+ return self._merge_hybrid_results(
+ vector=vector_results,
+ keyword=keyword_results,
+ vector_weight=vector_weight,
+ text_weight=text_weight,
+ )[:limit]
+ elif self.vector_enabled:
+ return await self.vector_search(query, limit, sources)
+ elif self.fts_enabled:
+ return await self.keyword_search(query, limit, sources)
+ else:
+ return []
+
+ @staticmethod
+ def _merge_hybrid_results(
+ vector: list[MemorySearchResult],
+ keyword: list[MemorySearchResult],
+ vector_weight: float,
+ text_weight: float,
+ ) -> list[MemorySearchResult]:
+ """Merge vector and keyword search results with weighted scoring."""
+ merged: dict[str, MemorySearchResult] = {}
+
+ for result in vector:
+ result.score = result.score * vector_weight
+ merged[result.merge_key] = result
+
+ for result in keyword:
+ key = result.merge_key
+ if key in merged:
+ merged[key].score += result.score * text_weight
+ else:
+ result.score = result.score * text_weight
+ merged[key] = result
+
+ results = list(merged.values())
+ results.sort(key=lambda r: r.score, reverse=True)
+ return results
+
+ # ------------------------------------------------------------------
+ # Maintenance
+ # ------------------------------------------------------------------
+
+ async def clear_all(self) -> None:
+ """Clear all indexed data."""
+ # Delete all documents
+ stats = self._collection.stats
+ count = stats.doc_count if stats else 0
+ if count > 0:
+ try:
+ self._collection.delete_by_filter("content!=''")
+ except Exception:
+ remaining = count
+ while remaining > 0:
+ batch = self._collection.query(
+ topk=min(remaining, _ZVEC_MAX_TOPK),
+ include_vector=False,
+ )
+ if not batch:
+ break
+ self._collection.delete([doc.id for doc in batch])
+ remaining -= len(batch)
+
+ self._metadata_cache = {}
+ await self._save_metadata({})
+ logger.info(f"Cleared all data from zvec file store: {self.collection_name}")
diff --git a/reme/core/vector_store/__init__.py b/reme/core/vector_store/__init__.py
index 8429b911..0426fd64 100644
--- a/reme/core/vector_store/__init__.py
+++ b/reme/core/vector_store/__init__.py
@@ -7,6 +7,7 @@ from .local_vector_store import LocalVectorStore
from .obvec_vector_store import ObVecVectorStore
from .pgvector_store import PGVectorStore
from .qdrant_vector_store import QdrantVectorStore
+from .zvec_vector_store import ZvecVectorStore
from ..registry_factory import R
__all__ = [
@@ -17,6 +18,7 @@ __all__ = [
"ObVecVectorStore",
"PGVectorStore",
"QdrantVectorStore",
+ "ZvecVectorStore",
]
R.vector_stores.register("chroma")(ChromaVectorStore)
@@ -25,3 +27,4 @@ R.vector_stores.register("local")(LocalVectorStore)
R.vector_stores.register("obvec")(ObVecVectorStore)
R.vector_stores.register("pgvector")(PGVectorStore)
R.vector_stores.register("qdrant")(QdrantVectorStore)
+R.vector_stores.register("zvec")(ZvecVectorStore)
diff --git a/reme/core/vector_store/zvec_vector_store.py b/reme/core/vector_store/zvec_vector_store.py
new file mode 100644
index 00000000..dde12a0b
--- /dev/null
+++ b/reme/core/vector_store/zvec_vector_store.py
@@ -0,0 +1,809 @@
+"""Zvec vector store implementation for the ReMe framework."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+
+from .base_vector_store import BaseVectorStore
+from ..embedding import BaseEmbeddingModel
+from ..schema import VectorNode
+
+_ZVEC_IMPORT_ERROR: Exception | None = None
+
+try:
+ import zvec # type: ignore[import-untyped]
+ from zvec import (
+ CollectionOption,
+ CollectionSchema,
+ DataType,
+ Doc,
+ FieldSchema,
+ HnswIndexParam,
+ InvertIndexParam,
+ VectorQuery,
+ VectorSchema,
+ )
+ from zvec.typing import MetricType
+except Exception as e:
+ _ZVEC_IMPORT_ERROR = e
+ zvec = None # type: ignore[assignment]
+
+
+# Default vector field name used inside zvec collections
+_DEFAULT_VECTOR_FIELD = "embedding"
+
+# Default scalar content field name for storing text
+_CONTENT_FIELD = "content"
+
+# Field name for JSON-serialized metadata
+_METADATA_FIELD = "metadata"
+
+# Metadata fields promoted to top-level zvec schema columns for native filtering.
+# These are the most commonly filtered keys in ReMe's memory system.
+# Defining them as independent schema columns allows zvec to perform
+# filtering at the database level instead of Python post-filtering.
+# Format: {metadata_key: (zvec_data_type_str, has_inverted_index)}
+_PROMOTED_FIELD_SPECS: dict[str, tuple[str, bool]] = {
+ "memory_type": ("STRING", True), # Inverted index for exact match filtering
+ "memory_target": ("STRING", True), # Inverted index for exact match filtering
+ "author": ("STRING", False),
+ "time_int": ("INT64", False), # Numeric for range queries
+}
+
+# zvec data-type string → DataType enum mapping (populated after import)
+_DATATYPE_MAP: dict[str, Any] = {} # filled in _build_collection_schema
+
+
+def _escape_zvec_string(value: str) -> str:
+ """Escape a string value for use in zvec filter expressions."""
+ return value.replace("'", "\\'")
+
+
+def _build_zvec_filter(
+ filters: dict | None,
+ promoted_fields: set[str],
+) -> tuple[str | None, dict | None]:
+ """Split ReMe filter dict into a zvec native filter expression and remaining post-filters.
+
+ For filter keys that correspond to promoted schema fields, native
+ zvec filter expressions are generated. Non-promoted keys are
+ kept for Python post-filtering.
+
+ Args:
+ filters: ReMe-style filter dictionary.
+ promoted_fields: Set of metadata keys that exist as top-level schema columns.
+
+ Returns:
+ (native_filter_expr, post_filter_dict) — either may be None.
+ """
+ if not filters:
+ return None, None
+
+ native_conditions: list[str] = []
+ post_filters: dict = {}
+
+ for key, value in filters.items():
+ if key.startswith("$"):
+ # Compound operators ($or, $and, $not) — keep for post-filtering
+ post_filters[key] = value
+ continue
+
+ if key not in promoted_fields:
+ # Not a promoted field — use post-filtering
+ post_filters[key] = value
+ continue
+
+ # Build native filter condition for promoted fields
+ field_type = _PROMOTED_FIELD_SPECS.get(key, ("STRING", False))[0]
+
+ if isinstance(value, list) and len(value) == 2:
+ # Range query: [start, end]
+ if field_type == "INT64":
+ native_conditions.append(f"{key} >= {value[0]} AND {key} <= {value[1]}")
+ else:
+ # STRING range — use >= and <= with string escaping
+ native_conditions.append(
+ f"{key} >= '{_escape_zvec_string(str(value[0]))}' "
+ f"AND {key} <= '{_escape_zvec_string(str(value[1]))}'",
+ )
+ elif isinstance(value, bool):
+ native_conditions.append(f"{key} = {str(value).upper()}")
+ elif isinstance(value, (int, float)):
+ native_conditions.append(f"{key} = {value}")
+ elif isinstance(value, str):
+ native_conditions.append(f"{key} = '{_escape_zvec_string(value)}'")
+ else:
+ # Unsupported type — fall back to post-filtering
+ post_filters[key] = value
+
+ native_filter = " AND ".join(native_conditions) if native_conditions else None
+ return native_filter, post_filters if post_filters else None
+
+
+def _metric_type_from_str(metric: str) -> Any:
+ """Convert a string metric name to zvec MetricType enum value."""
+ if zvec is None:
+ return None
+ mapping = {
+ "cosine": MetricType.COSINE,
+ "l2": MetricType.L2,
+ "ip": MetricType.IP,
+ }
+ return mapping.get(metric.lower(), MetricType.COSINE)
+
+
+def _build_collection_schema(
+ name: str,
+ dimension: int,
+ metric: str = "cosine",
+) -> CollectionSchema:
+ """Build a zvec CollectionSchema for ReMe usage.
+
+ The schema contains:
+ - "content" (STRING, inverted index) — text content
+ - "metadata" (STRING) — JSON-serialized metadata dictionary
+ - Promoted metadata fields (STRING / INT64) — for native zvec filtering
+ - "embedding" (VECTOR_FP32, dimension, HNSW index) — the vector field
+
+ Promoted fields are commonly filtered metadata keys defined as top-level
+ schema columns so that zvec can perform filtering natively instead of
+ Python post-filtering. The full metadata is still stored as JSON in the
+ "metadata" field for complete round-trip serialization.
+
+ zvec automatically manages the document ID (string type); we do NOT
+ define an "id" field in the schema.
+ """
+ # Populate the DataType map on first call
+ if not _DATATYPE_MAP:
+ _DATATYPE_MAP.update(
+ {
+ "STRING": DataType.STRING,
+ "INT64": DataType.INT64,
+ },
+ )
+
+ distance = _metric_type_from_str(metric)
+
+ # Base fields
+ fields = [
+ FieldSchema("content", DataType.STRING, nullable=True, index_param=InvertIndexParam()),
+ FieldSchema("metadata", DataType.STRING, nullable=True),
+ ]
+
+ # Add promoted metadata fields as top-level schema columns
+ for field_name, (type_str, has_inv_index) in _PROMOTED_FIELD_SPECS.items():
+ dt = _DATATYPE_MAP[type_str]
+ idx_param = InvertIndexParam() if has_inv_index else None
+ fields.append(FieldSchema(field_name, dt, nullable=True, index_param=idx_param))
+
+ return CollectionSchema(
+ name=name,
+ fields=fields,
+ vectors=[
+ VectorSchema(
+ name=_DEFAULT_VECTOR_FIELD,
+ data_type=DataType.VECTOR_FP32,
+ dimension=dimension,
+ index_param=HnswIndexParam(metric_type=distance),
+ ),
+ ],
+ )
+
+
+def _vector_node_to_doc(node: VectorNode) -> Doc:
+ """Convert a ReMe VectorNode to a zvec Doc.
+
+ Metadata is serialized as a JSON string into the "metadata" field.
+ The "score" key is excluded since it is a computed value, not stored data.
+ Promoted metadata fields are also extracted as top-level Doc fields
+ for native zvec filtering.
+ The vector is placed under the default vector field name.
+ The zvec Doc id must be a string.
+ """
+ # Filter out computed score before serialization
+ meta_to_store = {k: v for k, v in node.metadata.items() if k != "score"}
+
+ fields: dict[str, Any] = {
+ "content": node.content,
+ "metadata": json.dumps(meta_to_store) if meta_to_store else "{}",
+ }
+
+ # Extract promoted metadata fields as top-level schema columns
+ for field_name, (type_str, _) in _PROMOTED_FIELD_SPECS.items():
+ value = meta_to_store.get(field_name)
+ if value is not None:
+ # Ensure correct type: INT64 fields must be int
+ if type_str == "INT64" and not isinstance(value, int):
+ try:
+ value = int(value)
+ except (ValueError, TypeError):
+ continue
+ fields[field_name] = value
+
+ vectors: dict[str, Any] = {}
+ if node.vector is not None:
+ vectors[_DEFAULT_VECTOR_FIELD] = node.vector
+
+ return Doc(id=str(node.vector_id), fields=fields, vectors=vectors)
+
+
+def _doc_to_vector_node(doc: Doc, include_score: bool = False) -> VectorNode:
+ """Convert a zvec Doc back to a ReMe VectorNode.
+
+ The "metadata" field is parsed from JSON. The "content" field becomes
+ the node content. If ``include_score`` is True, the search score is
+ added to the metadata dictionary.
+ """
+ metadata: dict[str, str | bool | int | float] = {}
+
+ # Parse JSON metadata
+ raw_metadata = doc.field("metadata")
+ if raw_metadata:
+ try:
+ parsed = json.loads(raw_metadata)
+ if isinstance(parsed, dict):
+ metadata.update(parsed)
+ except (json.JSONDecodeError, TypeError):
+ logger.warning(f"Failed to parse metadata JSON: {raw_metadata}")
+
+ if include_score and doc.score is not None:
+ metadata["score"] = doc.score
+
+ # Extract vector — doc.vector() returns list or empty dict
+ raw_vector = doc.vector(_DEFAULT_VECTOR_FIELD)
+ vector = raw_vector if isinstance(raw_vector, list) and len(raw_vector) > 0 else None
+
+ content = doc.field("content") or ""
+
+ return VectorNode(
+ vector_id=str(doc.id),
+ content=str(content),
+ vector=vector,
+ metadata=metadata,
+ )
+
+
+def _apply_filters_post(nodes: list[VectorNode], filters: dict | None) -> list[VectorNode]:
+ """Apply ReMe-style filter dict as post-filtering on metadata.
+
+ Used as a fallback for metadata keys that are NOT promoted to top-level
+ schema columns (and thus cannot be filtered natively by zvec). Promoted
+ fields are handled by zvec's native ``filter`` parameter instead.
+
+ Supports:
+ - Exact match: {"field": value}
+ - Range query: {"field": [start, end]}
+ """
+ if not filters:
+ return nodes
+
+ filtered = []
+ for node in nodes:
+ match = True
+ for key, value in filters.items():
+ if key.startswith("$"):
+ # Skip compound operators for post-filtering
+ continue
+ node_value = node.metadata.get(key)
+
+ # Range query: [start, end]
+ if isinstance(value, list) and len(value) == 2:
+ if node_value is None:
+ match = False
+ break
+ try:
+ if not value[0] <= node_value <= value[1]:
+ match = False
+ break
+ except TypeError:
+ match = False
+ break
+ else:
+ # Exact match
+ if node_value != value:
+ match = False
+ break
+
+ if match:
+ filtered.append(node)
+
+ return filtered
+
+
+class ZvecVectorStore(BaseVectorStore):
+ """Zvec-based vector store implementation.
+
+ Zvec is a high-performance vector database. This adapter bridges the
+ ReMe ``BaseVectorStore`` interface with zvec's Python API.
+
+ Supports local persistent storage via ``db_path``.
+
+ Args:
+ collection_name: Name of the vector collection.
+ db_path: Local storage path for persistent mode.
+ embedding_model: Model used for generating vector embeddings.
+ dimension: Dimensionality of the embedding vectors (default: 1024).
+ distance: Distance metric — cosine / l2 / ip (default: cosine).
+ **kwargs: Additional zvec-specific configuration.
+ """
+
+ def __init__(
+ self,
+ collection_name: str,
+ db_path: str | Path,
+ embedding_model: BaseEmbeddingModel,
+ dimension: int = 1024,
+ distance: str = "cosine",
+ **kwargs: Any,
+ ):
+ """Initialize the Zvec vector store."""
+ if _ZVEC_IMPORT_ERROR is not None:
+ raise ImportError(
+ "Zvec requires extra dependencies. Install with `pip install zvec`",
+ ) from _ZVEC_IMPORT_ERROR
+
+ super().__init__(
+ collection_name=collection_name,
+ db_path=db_path,
+ embedding_model=embedding_model,
+ **kwargs,
+ )
+
+ self.dimension = dimension
+ self.distance = distance
+ self._collection = None
+ self._initialized = False
+ # Set of promoted field names that exist in the current collection's schema.
+ # Populated during start() by inspecting the schema. Only fields present
+ # in the schema can use native zvec filtering; the rest fall back to
+ # Python post-filtering.
+ self._promoted_fields_in_schema: set[str] = set()
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+
+ async def start(self) -> None:
+ """Initialize the Zvec engine and open the collection.
+
+ Calls ``zvec.init()`` once, then tries to ``zvec.open()`` an existing
+ collection or ``zvec.create_and_open()`` a new one.
+ After opening, detects which promoted fields exist in the schema
+ and attempts to add missing numeric fields via ``add_column``.
+ """
+ if not self._initialized:
+ try:
+ zvec.init()
+ except RuntimeError:
+ # Already initialized — safe to ignore
+ pass
+ self._initialized = True
+
+ self.db_path.mkdir(parents=True, exist_ok=True)
+ collection_path = str(self.db_path / self.collection_name)
+
+ option = CollectionOption(read_only=False, enable_mmap=True)
+
+ try:
+ # Try opening an existing collection first
+ self._collection = zvec.open(collection_path, option)
+ logger.info(f"Opened existing Zvec collection at {collection_path}")
+ except Exception:
+ # Collection doesn't exist — create it
+ schema = _build_collection_schema(
+ name=self.collection_name,
+ dimension=self.dimension,
+ metric=self.distance,
+ )
+ self._collection = zvec.create_and_open(
+ path=collection_path,
+ schema=schema,
+ option=option,
+ )
+ logger.info(f"Created new Zvec collection at {collection_path}")
+
+ # Detect which promoted fields exist in the current schema
+ self._detect_promoted_fields()
+
+ # Try to add missing numeric promoted fields to existing collections
+ # (zvec's add_column only supports numeric types: INT64, FLOAT, etc.)
+ self._ensure_numeric_promoted_columns()
+
+ async def close(self) -> None:
+ """Flush pending writes and release the collection handle."""
+ if self._collection is not None:
+ try:
+ self._collection.flush()
+ except Exception as e:
+ logger.warning(f"Failed to flush collection on close: {e}")
+ self._collection = None
+ logger.info(f"Zvec vector store for collection {self.collection_name} closed")
+
+ # ------------------------------------------------------------------
+ # Collection management
+ # ------------------------------------------------------------------
+
+ async def list_collections(self) -> list[str]:
+ """Retrieve a list of collection names in the db_path directory.
+
+ Zvec doesn't have a global ``list_collections`` API; we scan the
+ db_path directory for zvec collection folders.
+ """
+ if not self.db_path.exists():
+ return []
+ collections = []
+ for child in self.db_path.iterdir():
+ if child.is_dir():
+ collections.append(child.name)
+ return collections
+
+ async def create_collection(self, collection_name: str, **kwargs) -> None:
+ """Create a new collection with the specified name and distance metric."""
+ if not self._initialized:
+ try:
+ zvec.init()
+ except RuntimeError:
+ pass
+ self._initialized = True
+
+ self.db_path.mkdir(parents=True, exist_ok=True)
+ collection_path = str(self.db_path / collection_name)
+
+ dimension = kwargs.get("dimension", self.dimension)
+ metric = kwargs.get("distance_metric", self.distance)
+
+ schema = _build_collection_schema(
+ name=collection_name,
+ dimension=dimension,
+ metric=metric,
+ )
+ option = CollectionOption(read_only=False, enable_mmap=True)
+
+ collection = zvec.create_and_open(path=collection_path, schema=schema, option=option)
+ if collection_name == self.collection_name:
+ self._collection = collection
+ logger.info(f"Created collection `{collection_name}`")
+
+ async def delete_collection(self, collection_name: str, **kwargs) -> None:
+ """Permanently remove a collection from disk."""
+ # If it's the active collection, destroy it via zvec API
+ if self._collection is not None and collection_name == self.collection_name:
+ try:
+ self._collection.destroy()
+ self._collection = None
+ deleted = True
+ except Exception as _e:
+ logger.warning(f"Failed to destroy collection {collection_name}: {_e}")
+ deleted = False
+ else:
+ # For non-active collections, remove the directory
+ collection_path = self.db_path / collection_name
+ if collection_path.exists():
+ import shutil
+
+ shutil.rmtree(collection_path, ignore_errors=True)
+ deleted = True
+ else:
+ deleted = False
+
+ logger.info(f"Deleted collection {collection_name}: {deleted}")
+
+ async def copy_collection(self, collection_name: str, **kwargs) -> None:
+ """Duplicate the current collection to a new one with the given name.
+
+ Uses ``shutil.copytree`` to directly copy the collection directory on
+ disk, which is both faster and complete — it avoids the topk limit of
+ ``list()`` (max 1024 docs) that would cause data loss for large
+ collections.
+
+ The source collection is flushed before copying to ensure all
+ pending writes are persisted to disk.
+ """
+ import shutil
+
+ # Flush source collection so all data is on disk
+ if self._collection is not None:
+ self._collection.flush()
+
+ src_path = self.db_path / self.collection_name
+ dst_path = self.db_path / collection_name
+
+ if not src_path.exists():
+ logger.warning(f"Source collection directory not found: {src_path}")
+ return
+
+ if dst_path.exists():
+ logger.warning(f"Target collection already exists: {dst_path}, removing it first")
+ shutil.rmtree(dst_path, ignore_errors=True)
+
+ shutil.copytree(src_path, dst_path)
+ logger.info(
+ f"Copied collection {self.collection_name} to {collection_name} "
+ f"(directory copy: {src_path} -> {dst_path})",
+ )
+
+ # ------------------------------------------------------------------
+ # CRUD operations
+ # ------------------------------------------------------------------
+
+ async def insert(self, nodes: VectorNode | list[VectorNode], **kwargs) -> None:
+ """Add one or more vector nodes into the current collection.
+
+ Automatically generates embeddings for nodes that lack vectors.
+ """
+ if isinstance(nodes, VectorNode):
+ nodes = [nodes]
+ if not nodes:
+ return
+
+ # Batch generate embeddings for nodes that need them
+ nodes_without_vectors = [n for n in nodes if n.vector is None]
+ if nodes_without_vectors:
+ nodes_with_vectors = await self.get_node_embeddings(nodes_without_vectors)
+ vector_map = {n.vector_id: n for n in nodes_with_vectors}
+ nodes_to_insert = [vector_map.get(n.vector_id, n) if n.vector is None else n for n in nodes]
+ else:
+ nodes_to_insert = nodes
+
+ batch_size = kwargs.get("batch_size", 100)
+
+ for i in range(0, len(nodes_to_insert), batch_size):
+ batch = nodes_to_insert[i : i + batch_size]
+ docs = [_vector_node_to_doc(n) for n in batch]
+ self._collection.insert(docs)
+
+ logger.info(f"Inserted {len(nodes_to_insert)} nodes into {self.collection_name}")
+
+ async def search(
+ self,
+ query: str,
+ limit: int = 5,
+ filters: dict | None = None,
+ **kwargs,
+ ) -> list[VectorNode]:
+ """Find the most similar vector nodes based on a text query.
+
+ Uses zvec's ``query()`` method with a ``VectorQuery`` built from the
+ embedding of the query text. Promoted metadata fields are filtered
+ natively via zvec's ``filter`` parameter; remaining filters are
+ applied as post-filtering in Python.
+ """
+ query_vector = await self.get_embedding(query)
+
+ vq = VectorQuery(
+ field_name=_DEFAULT_VECTOR_FIELD,
+ vector=query_vector,
+ )
+ include_vector = kwargs.get("include_embeddings", False)
+
+ # Split filters: native zvec filter vs Python post-filter
+ native_filter, post_filters = _build_zvec_filter(filters, self._promoted_fields_in_schema)
+
+ # Over-fetch to compensate for post-filtering
+ _ZVEC_MAX_TOPK = 1024
+ # When post-filters remain, we need to fetch more results because
+ # many may be filtered out. Use the maximum allowed to minimize misses.
+ fetch_limit = _ZVEC_MAX_TOPK if post_filters else min(limit, _ZVEC_MAX_TOPK)
+
+ results = self._collection.query(
+ vectors=vq,
+ topk=fetch_limit,
+ filter=native_filter,
+ include_vector=include_vector,
+ )
+
+ nodes = [_doc_to_vector_node(doc, include_score=True) for doc in results]
+
+ # Post-filter on non-promoted metadata fields
+ nodes = _apply_filters_post(nodes, post_filters)
+
+ score_threshold = kwargs.get("score_threshold")
+ if score_threshold is not None:
+ nodes = [n for n in nodes if n.metadata.get("score", 0) >= score_threshold]
+
+ return nodes[:limit]
+
+ async def delete(self, vector_ids: str | list[str], **kwargs) -> None:
+ """Remove specific vectors from the collection using their identifiers."""
+ if isinstance(vector_ids, str):
+ vector_ids = [vector_ids]
+ if not vector_ids:
+ return
+
+ self._collection.delete(vector_ids)
+ logger.info(f"Deleted {len(vector_ids)} nodes from {self.collection_name}")
+
+ async def delete_all(self, **kwargs) -> None:
+ """Remove all vectors from the collection.
+
+ Uses zvec's ``delete_by_filter`` with a condition that matches all
+ documents (content is not empty), or falls back to query + delete
+ in batches (zvec topk max is 1024).
+ """
+ stats = self._collection.stats
+ count = stats.doc_count if stats else 0
+ if count > 0:
+ try:
+ # Use delete_by_filter for efficiency
+ self._collection.delete_by_filter("content!=''")
+ except Exception:
+ # Fallback: fetch all IDs in batches then delete
+ _ZVEC_MAX_TOPK = 1024
+ remaining = count
+ while remaining > 0:
+ all_docs = self._collection.query(topk=min(remaining, _ZVEC_MAX_TOPK), include_vector=False)
+ if not all_docs:
+ break
+ ids = [doc.id for doc in all_docs]
+ self._collection.delete(ids)
+ remaining -= len(ids)
+ logger.info(f"Deleted all {count} nodes from {self.collection_name}")
+
+ async def update(self, nodes: VectorNode | list[VectorNode], **kwargs) -> None:
+ """Update existing vectors using zvec's ``upsert``.
+
+ Automatically regenerates embeddings for nodes whose content changed
+ but lack an updated vector.
+ """
+ if isinstance(nodes, VectorNode):
+ nodes = [nodes]
+ if not nodes:
+ return
+
+ # Batch generate embeddings for nodes that need them
+ nodes_without_vectors = [n for n in nodes if n.vector is None and n.content]
+ if nodes_without_vectors:
+ nodes_with_vectors = await self.get_node_embeddings(nodes_without_vectors)
+ vector_map = {n.vector_id: n for n in nodes_with_vectors}
+ nodes_to_update = [vector_map.get(n.vector_id, n) if n.vector is None and n.content else n for n in nodes]
+ else:
+ nodes_to_update = nodes
+
+ docs = [_vector_node_to_doc(n) for n in nodes_to_update]
+ self._collection.upsert(docs)
+ logger.info(f"Updated {len(nodes_to_update)} nodes in {self.collection_name}")
+
+ async def get(self, vector_ids: str | list[str]) -> VectorNode | list[VectorNode]:
+ """Fetch specific vector nodes from the collection by their IDs."""
+ is_single = isinstance(vector_ids, str)
+ ids = [vector_ids] if is_single else vector_ids
+
+ result_dict = self._collection.fetch(ids)
+ nodes = [_doc_to_vector_node(doc) for doc in result_dict.values()]
+ return nodes[0] if is_single and nodes else (nodes if not is_single else None)
+
+ async def list(
+ self,
+ filters: dict | None = None,
+ limit: int | None = None,
+ sort_key: str | None = None,
+ reverse: bool = True,
+ ) -> list[VectorNode]:
+ """Retrieve vectors matching optional metadata filters.
+
+ Uses zvec's ``query()`` without a vector query to list all documents.
+ Promoted metadata fields are filtered natively via zvec's ``filter``
+ parameter; remaining filters are applied as post-filtering in Python.
+
+ Args:
+ filters: Dictionary of filter conditions to match vectors.
+ limit: Maximum number of vectors to return.
+ sort_key: Key to sort the results by (in metadata).
+ reverse: If True, sort in descending order; otherwise ascending.
+ """
+ # Split filters: native zvec filter vs Python post-filter
+ native_filter, post_filters = _build_zvec_filter(filters, self._promoted_fields_in_schema)
+
+ # Determine fetch limit — zvec max topk is 1024 (will be lifted to 100,000 in zvec v0.3.2+)
+ _ZVEC_MAX_TOPK = 1024
+ fetch_limit = min(limit or _ZVEC_MAX_TOPK, _ZVEC_MAX_TOPK)
+ if sort_key or post_filters:
+ fetch_limit = _ZVEC_MAX_TOPK # fetch max and sort/filter in Python
+
+ results = self._collection.query(
+ topk=fetch_limit,
+ filter=native_filter,
+ include_vector=True,
+ )
+
+ nodes = [_doc_to_vector_node(doc) for doc in results]
+
+ # Post-filter on non-promoted metadata fields
+ nodes = _apply_filters_post(nodes, post_filters)
+
+ # Apply sorting if sort_key is provided
+ if sort_key:
+
+ def _sort_key_func(node: VectorNode):
+ value = node.metadata.get(sort_key)
+ if value is None:
+ return float("-inf") if not reverse else float("inf")
+ return value
+
+ nodes.sort(key=_sort_key_func, reverse=reverse)
+
+ if limit is not None:
+ nodes = nodes[:limit]
+
+ return nodes
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _detect_promoted_fields(self) -> None:
+ """Detect which promoted fields exist in the current collection's schema.
+
+ Compares the set of promoted field names against the actual schema
+ and populates ``_promoted_fields_in_schema`` accordingly. Only fields
+ present in the schema can use native zvec filtering.
+ """
+ if self._collection is None:
+ return
+
+ try:
+ schema = self._collection.schema
+ existing_fields = {f.name for f in schema.fields} if schema.fields else set()
+ except Exception as e:
+ logger.warning(f"Failed to read collection schema: {e}")
+ existing_fields = set()
+
+ self._promoted_fields_in_schema = set(_PROMOTED_FIELD_SPECS.keys()) & existing_fields
+
+ missing = set(_PROMOTED_FIELD_SPECS.keys()) - existing_fields
+ if missing:
+ logger.info(
+ f"Promoted fields not in schema (will use post-filtering): {missing}",
+ )
+
+ def _ensure_numeric_promoted_columns(self) -> None:
+ """Add missing numeric promoted fields to existing collections.
+
+ zvec's ``add_column`` only supports numeric types (INT64, FLOAT, etc.).
+ STRING fields cannot be added via ``add_column`` and must be defined
+ at collection creation time. For those, we fall back to post-filtering.
+ """
+ if self._collection is None:
+ return
+
+ missing = set(_PROMOTED_FIELD_SPECS.keys()) - self._promoted_fields_in_schema
+ if not missing:
+ return
+
+ # Populate the DataType map if needed
+ if not _DATATYPE_MAP:
+ _DATATYPE_MAP.update(
+ {
+ "STRING": DataType.STRING,
+ "INT64": DataType.INT64,
+ },
+ )
+
+ for field_name in missing:
+ type_str, _ = _PROMOTED_FIELD_SPECS[field_name]
+ # Only numeric types can be added via add_column
+ if type_str not in ("INT64", "INT32", "FLOAT", "DOUBLE"):
+ continue
+ try:
+ dt = _DATATYPE_MAP[type_str]
+ self._collection.add_column(FieldSchema(field_name, dt, nullable=True))
+ self._promoted_fields_in_schema.add(field_name)
+ logger.info(f"Added promoted column '{field_name}' to existing collection")
+ except Exception as e:
+ logger.warning(f"Failed to add column '{field_name}': {e}")
+
+ async def count(self) -> int:
+ """Return the total number of documents in the current collection."""
+ stats = self._collection.stats
+ return stats.doc_count if stats else 0
+
+ async def reset(self):
+ """Reset the current collection by destroying and recreating it."""
+ logger.warning(f"Resetting collection {self.collection_name}...")
+ await self.delete_collection(self.collection_name)
+ await self.create_collection(self.collection_name)
+ logger.info(f"Collection {self.collection_name} has been reset")
diff --git a/tests/test_file_store.py b/tests/test_file_store.py
index fd44332d..0e34764f 100644
--- a/tests/test_file_store.py
+++ b/tests/test_file_store.py
@@ -28,6 +28,7 @@ from reme.core.file_store.base_file_store import BaseFileStore
from reme.core.file_store.chroma_file_store import ChromaFileStore
from reme.core.file_store.local_file_store import LocalFileStore
from reme.core.file_store.sqlite_file_store import SqliteFileStore
+from reme.core.file_store.zvec_file_store import ZvecFileStore
from reme.core.schema.file_metadata import FileMetadata
from reme.core.schema.memory_chunk import MemoryChunk
from reme.core.utils import load_env
@@ -53,6 +54,10 @@ class TestConfig:
CHROMA_DB_PATH = "./test_file_store_chroma"
CHROMA_FTS_ENABLED = True
+ # ZvecFileStore settings
+ ZVEC_DB_PATH = "./test_file_store_zvec"
+ ZVEC_FTS_ENABLED = True
+
# LocalFileStore settings
LOCAL_DB_PATH = "./test_file_store_local"
LOCAL_FTS_ENABLED = True
@@ -199,6 +204,8 @@ def get_store_type(store: BaseFileStore) -> str:
return "chroma"
elif isinstance(store, LocalFileStore):
return "local"
+ elif isinstance(store, ZvecFileStore):
+ return "zvec"
else:
raise ValueError(f"Unknown file store type: {type(store)}")
@@ -242,6 +249,14 @@ def create_file_store(store_type: str) -> BaseFileStore:
embedding_model=embedding_model,
fts_enabled=config.LOCAL_FTS_ENABLED,
)
+ elif store_type == "zvec":
+ return ZvecFileStore(
+ store_name=config.NAME,
+ db_path=config.ZVEC_DB_PATH,
+ embedding_model=embedding_model,
+ fts_enabled=config.ZVEC_FTS_ENABLED,
+ dimension=config.EMBEDDING_DIMENSIONS,
+ )
else:
raise ValueError(f"Unknown store type: {store_type}")
@@ -284,6 +299,13 @@ async def test_start_store(store: BaseFileStore, _store_name: str):
assert isinstance(store._files, dict), "Files index should be a dict"
logger.info(f"✓ LocalFileStore ready (chunks file: {store._chunks_file})")
+ # Verify ZvecFileStore initialized
+ if isinstance(store, ZvecFileStore):
+ # pylint: disable=protected-access
+ assert store._collection is not None, "Zvec collection should be initialized"
+ assert store._initialized, "Zvec engine should be initialized"
+ logger.info(f"✓ ZvecFileStore ready (collection: {store.collection_name})")
+
async def test_upsert_file(store: BaseFileStore, _store_name: str) -> tuple[FileMetadata, List[MemoryChunk]]:
"""Test file and chunks insertion."""
@@ -1013,6 +1035,18 @@ async def cleanup_store(store: BaseFileStore, store_type: str):
json_file.unlink()
logger.info(f"✓ Cleaned up file: {json_file}")
+ # Clean up zvec directory and metadata file
+ if store_type == "zvec":
+ config = TestConfig()
+ db_dir = Path(config.ZVEC_DB_PATH)
+ if db_dir.exists():
+ shutil.rmtree(db_dir)
+ logger.info(f"✓ Cleaned up directory: {db_dir}")
+ metadata_file = db_dir.parent / f"{config.NAME}_file_metadata.json"
+ if metadata_file.exists():
+ metadata_file.unlink()
+ logger.info(f"✓ Cleaned up metadata file: {metadata_file}")
+
logger.info("✓ Cleanup completed")
except Exception as e:
logger.error(f"Cleanup error: {e}")
@@ -1049,6 +1083,11 @@ Examples:
action="store_true",
help="Test LocalFileStore",
)
+ parser.add_argument(
+ "--zvec",
+ action="store_true",
+ help="Test ZvecFileStore",
+ )
parser.add_argument(
"--all",
action="store_true",
@@ -1065,6 +1104,7 @@ Examples:
("sqlite", "SqliteFileStore"),
("chroma", "ChromaFileStore"),
("local", "LocalFileStore"),
+ ("zvec", "ZvecFileStore"),
]
else:
# Build list based on individual flags
@@ -1074,6 +1114,8 @@ Examples:
stores_to_test.append(("chroma", "ChromaFileStore"))
if args.local:
stores_to_test.append(("local", "LocalFileStore"))
+ if args.zvec:
+ stores_to_test.append(("zvec", "ZvecFileStore"))
if not stores_to_test:
# Default to all file stores if no argument provided
@@ -1081,9 +1123,10 @@ Examples:
("sqlite", "SqliteFileStore"),
("chroma", "ChromaFileStore"),
("local", "LocalFileStore"),
+ ("zvec", "ZvecFileStore"),
]
print("No file store specified, defaulting to test all file stores")
- print("Use --sqlite, --chroma, or --local to test specific ones\n")
+ print("Use --sqlite, --chroma, --local, or --zvec to test specific ones\n")
# Run tests for each file store
for store_type, store_name in stores_to_test:
diff --git a/tests/test_vector_store.py b/tests/test_vector_store.py
index 7f7264be..9b39ab9b 100644
--- a/tests/test_vector_store.py
+++ b/tests/test_vector_store.py
@@ -2,7 +2,7 @@
"""Unified test suite for vector store implementations.
This module provides comprehensive test coverage for LocalVectorStore, ESVectorStore,
-PGVectorStore, QdrantVectorStore, ChromaVectorStore, and ObVecVectorStore implementations.
+PGVectorStore, QdrantVectorStore, ChromaVectorStore, ObVecVectorStore and ZvecVectorStore implementations.
Tests can be run for specific vector stores or all implementations.
Usage:
@@ -12,6 +12,7 @@ Usage:
python test_vector_store.py --qdrant # Test QdrantVectorStore only
python test_vector_store.py --chroma # Test ChromaVectorStore only
python test_vector_store.py --obvec # Test ObVecVectorStore only (needs seekdb / OceanBase)
+ python test_vector_store.py --zvec # Test ZvecVectorStore only
python test_vector_store.py --all # Test all vector stores
"""
@@ -36,6 +37,7 @@ from reme.core.vector_store import (
ObVecVectorStore,
PGVectorStore,
QdrantVectorStore,
+ ZvecVectorStore,
)
load_env()
@@ -90,6 +92,9 @@ class TestConfig:
OBVEC_PASSWORD = os.environ.get("OBVEC_PASSWORD", "root")
OBVEC_DATABASE = os.environ.get("OBVEC_DATABASE", "test")
+ # ZvecVectorStore settings
+ ZVEC_PATH = "./test_vector_store_zvec" # For local persistent mode
+
# Embedding model settings
EMBEDDING_MODEL_NAME = "text-embedding-v4"
EMBEDDING_DIMENSIONS = 64
@@ -192,6 +197,7 @@ class SampleDataGenerator:
# ==================== Vector Store Factory ====================
+# pylint: disable=too-many-return-statements
def get_store_type(store: BaseVectorStore) -> str:
"""Get the type identifier of a vector store instance.
@@ -199,7 +205,7 @@ def get_store_type(store: BaseVectorStore) -> str:
store: Vector store instance
Returns:
- str: Type identifier ("local", "es", "pgvector", "qdrant", "chroma", or "obvec")
+ str: Type identifier ("local", "es", "pgvector", "qdrant", "chroma", "obvec", or "zvec")
"""
if isinstance(store, LocalVectorStore):
return "local"
@@ -213,10 +219,13 @@ def get_store_type(store: BaseVectorStore) -> str:
return "chroma"
elif isinstance(store, ObVecVectorStore):
return "obvec"
+ elif isinstance(store, ZvecVectorStore):
+ return "zvec"
else:
raise ValueError(f"Unknown vector store type: {type(store)}")
+# pylint: disable=too-many-return-statements
def create_vector_store(store_type: str, collection_name: str) -> BaseVectorStore:
"""Create a vector store instance based on type.
@@ -295,6 +304,14 @@ def create_vector_store(store_type: str, collection_name: str) -> BaseVectorStor
index_metric="cosine",
index_ef_search=100,
)
+ elif store_type == "zvec":
+ return ZvecVectorStore(
+ collection_name=collection_name,
+ embedding_model=embedding_model,
+ db_path=config.ZVEC_PATH or tempfile.mkdtemp(prefix="test_zvec_"),
+ dimension=config.EMBEDDING_DIMENSIONS,
+ distance="cosine",
+ )
else:
raise ValueError(f"Unknown store type: {store_type}")
@@ -1790,6 +1807,13 @@ async def cleanup_store(store: BaseVectorStore, store_type: str):
shutil.rmtree(obvec_dir, ignore_errors=True)
logger.info(f"Cleaned up obvec temp directory: {obvec_dir}")
+ # Clean up local directory if ZvecVectorStore
+ if store_type == "zvec" and config.ZVEC_PATH:
+ test_dir = Path(config.ZVEC_PATH)
+ if test_dir.exists():
+ shutil.rmtree(test_dir)
+ logger.info(f"Cleaned up zvec directory: {config.ZVEC_PATH}")
+
logger.info("✓ Cleanup completed")
except Exception as e:
logger.error(f"Cleanup error: {e}")
@@ -1844,6 +1868,11 @@ Examples:
action="store_true",
help="Test ObVecVectorStore",
)
+ parser.add_argument(
+ "--zvec",
+ action="store_true",
+ help="Test ZvecVectorStore",
+ )
parser.add_argument(
"--all",
action="store_true",
@@ -1863,6 +1892,7 @@ Examples:
("qdrant", "QdrantVectorStore"),
("chroma", "ChromaVectorStore"),
("obvec", "ObVecVectorStore"),
+ ("zvec", "ZvecVectorStore"),
]
else:
# Build list based on individual flags
@@ -1878,6 +1908,8 @@ Examples:
stores_to_test.append(("chroma", "ChromaVectorStore"))
if args.obvec:
stores_to_test.append(("obvec", "ObVecVectorStore"))
+ if args.zvec:
+ stores_to_test.append(("zvec", "ZvecVectorStore"))
if not stores_to_test:
# Default to all vector stores if no argument provided
@@ -1888,10 +1920,11 @@ Examples:
("qdrant", "QdrantVectorStore"),
("chroma", "ChromaVectorStore"),
("obvec", "ObVecVectorStore"),
+ ("zvec", "ZvecVectorStore"),
]
print("No vector store specified, defaulting to test all vector stores")
print(
- "Use --local/--es/--pgvector/--qdrant/--chroma/--obvec to test specific ones\n",
+ "Use --local/--es/--pgvector/--qdrant/--chroma/--obvec/--zvec to test specific ones\n",
)
# Run tests for each vector store
diff --git a/tests/test_zvec_vector_store.py b/tests/test_zvec_vector_store.py
new file mode 100644
index 00000000..59836f24
--- /dev/null
+++ b/tests/test_zvec_vector_store.py
@@ -0,0 +1,906 @@
+"""Test suite for ZvecVectorStore implementation.
+
+Comprehensive tests covering CRUD operations, search, filtering,
+collection management, and edge cases for the zvec vector store adapter.
+
+Usage:
+ python -m pytest tests/test_zvec_vector_store.py -v
+ python tests/test_zvec_vector_store.py
+"""
+
+# pylint: disable=redefined-outer-name,unused-argument
+
+from __future__ import annotations
+
+import asyncio
+import shutil
+import tempfile
+from pathlib import Path
+from typing import List
+from uuid import uuid4
+
+import pytest
+
+from loguru import logger
+
+from reme.core.schema import VectorNode
+from reme.core.vector_store import ZvecVectorStore
+
+# ---------------------------------------------------------------------------
+# Skip entire module if zvec native library is not installed
+# ---------------------------------------------------------------------------
+try:
+ import zvec as _zvec # noqa: F401 — just checking availability
+except ImportError:
+ pytest.skip("zvec native library not installed", allow_module_location=True)
+
+
+# ==================== Configuration ====================
+
+
+class TestConfig:
+ """Configuration for zvec test execution."""
+
+ ZVEC_ROOT_PATH = tempfile.mkdtemp(prefix="test_zvec_")
+ EMBEDDING_DIMENSION = 64 # Small dimension for faster tests
+ TEST_COLLECTION_PREFIX = "test_zvec_vs"
+
+
+# ==================== Sample Data ====================
+
+
+def create_sample_nodes(prefix: str = "") -> List[VectorNode]:
+ """Create sample VectorNode instances for testing."""
+ id_prefix = f"{prefix}_" if prefix else ""
+ return [
+ VectorNode(
+ vector_id=f"{id_prefix}node1",
+ content="Artificial intelligence is a technology that simulates human intelligence.",
+ metadata={
+ "node_type": "tech",
+ "category": "AI",
+ "source": "research",
+ "priority": "high",
+ "year": "2023",
+ },
+ ),
+ VectorNode(
+ vector_id=f"{id_prefix}node2",
+ content="Machine learning is a subset of artificial intelligence.",
+ metadata={
+ "node_type": "tech",
+ "category": "ML",
+ "source": "research",
+ "priority": "high",
+ "year": "2022",
+ },
+ ),
+ VectorNode(
+ vector_id=f"{id_prefix}node3",
+ content="Deep learning uses neural networks with multiple layers.",
+ metadata={
+ "node_type": "tech_new",
+ "category": "DL",
+ "source": "blog",
+ "priority": "medium",
+ "year": "2024",
+ },
+ ),
+ VectorNode(
+ vector_id=f"{id_prefix}node4",
+ content="I love eating delicious seafood, especially fresh fish.",
+ metadata={
+ "node_type": "food",
+ "category": "preference",
+ "source": "personal",
+ "priority": "low",
+ "year": "2023",
+ },
+ ),
+ VectorNode(
+ vector_id=f"{id_prefix}node5",
+ content="Natural language processing enables computers to understand human language.",
+ metadata={
+ "node_type": "tech",
+ "category": "NLP",
+ "source": "research",
+ "priority": "high",
+ "year": "2024",
+ },
+ ),
+ ]
+
+
+# ==================== Fixtures ====================
+
+
+class MockEmbeddingModel:
+ """A mock embedding model that generates deterministic random vectors.
+
+ Avoids external API calls during testing. Produces unit-normalized
+ vectors so that cosine similarity works correctly.
+ """
+
+ def __init__(self, dimension: int = 64):
+ self.dimension = dimension
+
+ async def get_embedding(self, query: str) -> list[float]:
+ """Generate a deterministic embedding from a query string."""
+ import hashlib
+ import struct
+
+ h = hashlib.sha256(query.encode()).digest()
+ # Repeat hash to fill dimension
+ full_hash = b""
+ while len(full_hash) < self.dimension * 4:
+ full_hash += hashlib.sha256(h + full_hash).digest()
+
+ vec = list(struct.unpack(f"<{self.dimension}f", full_hash[: self.dimension * 4]))
+ # Normalize to unit vector
+ norm = sum(x * x for x in vec) ** 0.5
+ if norm > 0:
+ vec = [x / norm for x in vec]
+ return vec
+
+ async def get_embeddings(self, queries: list[str]) -> list[list[float]]:
+ """Generate embeddings for multiple queries."""
+ return [await self.get_embedding(q) for q in queries]
+
+ async def get_node_embedding(self, node: VectorNode) -> VectorNode:
+ """Assign embedding to a single node."""
+ if node.content:
+ node.vector = await self.get_embedding(node.content)
+ return node
+
+ async def get_node_embeddings(self, nodes: list[VectorNode]) -> list[VectorNode]:
+ """Assign embeddings to multiple nodes."""
+ return [await self.get_node_embedding(n) for n in nodes]
+
+
+@pytest.fixture
+def embedding_model():
+ """Provide a MockEmbeddingModel for tests."""
+ return MockEmbeddingModel(dimension=TestConfig.EMBEDDING_DIMENSION)
+
+
+@pytest.fixture
+def zvec_store(embedding_model, tmp_path):
+ """Create and start a ZvecVectorStore for testing.
+
+ Yields the store and cleans up afterwards.
+ """
+ collection_name = f"{TestConfig.TEST_COLLECTION_PREFIX}_{uuid4().hex[:8]}"
+ store = ZvecVectorStore(
+ collection_name=collection_name,
+ db_path=str(tmp_path / "zvec_db"),
+ embedding_model=embedding_model,
+ dimension=TestConfig.EMBEDDING_DIMENSION,
+ distance="cosine",
+ )
+
+ async def _setup():
+ await store.start()
+ return store
+
+ store = asyncio.get_event_loop().run_until_complete(_setup())
+ yield store
+
+ async def _teardown():
+ try:
+ await store.close()
+ except Exception:
+ pass
+ # Clean up temp directory
+ db_path = Path(str(tmp_path / "zvec_db"))
+ if db_path.exists():
+ shutil.rmtree(db_path, ignore_errors=True)
+
+ asyncio.get_event_loop().run_until_complete(_teardown())
+
+
+# ==================== Helper ====================
+
+
+def run(coro):
+ """Run an async coroutine in the current event loop."""
+ return asyncio.get_event_loop().run_until_complete(coro)
+
+
+# ==================== Test: Collection Lifecycle ====================
+
+
+class TestCollectionLifecycle:
+ """Tests for collection creation, listing, deletion, and copy."""
+
+ def test_create_collection(self, zvec_store):
+ """Test that a collection is created during start()."""
+ collections = run(zvec_store.list_collections())
+ assert zvec_store.collection_name in collections
+
+ def test_list_collections(self, zvec_store):
+ """Test listing collections."""
+ collections = run(zvec_store.list_collections())
+ assert isinstance(collections, list)
+ assert len(collections) >= 1
+
+ def test_delete_collection(self, zvec_store, embedding_model, tmp_path):
+ """Test deleting a collection."""
+ # Create a secondary collection
+ coll_name = f"del_test_{uuid4().hex[:8]}"
+ store2 = ZvecVectorStore(
+ collection_name=coll_name,
+ db_path=str(tmp_path / "zvec_db"),
+ embedding_model=embedding_model,
+ dimension=TestConfig.EMBEDDING_DIMENSION,
+ )
+ run(store2.start())
+
+ collections = run(zvec_store.list_collections())
+ assert coll_name in collections
+
+ run(zvec_store.delete_collection(coll_name))
+
+ collections = run(zvec_store.list_collections())
+ assert coll_name not in collections
+
+ def test_copy_collection(self, zvec_store, embedding_model, tmp_path):
+ """Test copying a collection."""
+ # Insert some data first
+ nodes = create_sample_nodes("copy")
+ run(zvec_store.insert(nodes))
+
+ copy_name = f"copy_test_{uuid4().hex[:8]}"
+ run(zvec_store.copy_collection(copy_name))
+
+ # Verify copy exists
+ collections = run(zvec_store.list_collections())
+ assert copy_name in collections
+
+ # Clean up
+ run(zvec_store.delete_collection(copy_name))
+
+
+# ==================== Test: Insert ====================
+
+
+class TestInsert:
+ """Tests for node insertion (single and batch)."""
+
+ def test_insert_single_node(self, zvec_store):
+ """Test inserting a single node."""
+ node = VectorNode(
+ vector_id="single_1",
+ content="This is a single node insertion test",
+ metadata={"test_type": "single_insert"},
+ )
+ run(zvec_store.insert(node))
+
+ result = run(zvec_store.get("single_1"))
+ assert result is not None
+ assert result.vector_id == "single_1"
+ assert "single node" in result.content
+
+ def test_insert_batch_nodes(self, zvec_store):
+ """Test inserting multiple nodes in batch."""
+ nodes = create_sample_nodes("batch")
+ run(zvec_store.insert(nodes))
+
+ all_nodes = run(zvec_store.list(limit=10))
+ assert len(all_nodes) >= len(nodes)
+
+ def test_insert_node_with_vector(self, zvec_store):
+ """Test inserting a node that already has a vector."""
+ node = VectorNode(
+ vector_id="prevec_1",
+ content="Node with pre-computed vector",
+ vector=[0.1] * TestConfig.EMBEDDING_DIMENSION,
+ metadata={"test_type": "pre_vector"},
+ )
+ run(zvec_store.insert(node))
+
+ result = run(zvec_store.get("prevec_1"))
+ assert result is not None
+ assert result.vector is not None
+
+
+# ==================== Test: Search ====================
+
+
+class TestSearch:
+ """Tests for vector similarity search."""
+
+ @pytest.fixture(autouse=True)
+ def _insert_sample_data(self, zvec_store):
+ """Insert sample data before each search test."""
+ nodes = create_sample_nodes("search")
+ run(zvec_store.insert(nodes))
+
+ def test_basic_search(self, zvec_store):
+ """Test basic vector search."""
+ results = run(zvec_store.search(query="What is artificial intelligence?", limit=3))
+ assert len(results) > 0
+ for r in results:
+ assert isinstance(r, VectorNode)
+ assert r.content
+
+ def test_search_with_limit(self, zvec_store):
+ """Test search with various limits."""
+ results = run(zvec_store.search(query="technology", limit=2))
+ assert len(results) <= 2
+
+ def test_search_with_filter(self, zvec_store):
+ """Test vector search with metadata filter."""
+ results = run(
+ zvec_store.search(
+ query="What is artificial intelligence?",
+ limit=5,
+ filters={"node_type": "tech"},
+ ),
+ )
+ # All results should have node_type == "tech"
+ for r in results:
+ assert r.metadata.get("node_type") == "tech"
+
+ def test_search_with_multiple_filters(self, zvec_store):
+ """Test search with multiple metadata filters (AND)."""
+ results = run(
+ zvec_store.search(
+ query="What is artificial intelligence?",
+ limit=5,
+ filters={"node_type": "tech", "source": "research"},
+ ),
+ )
+ for r in results:
+ assert r.metadata.get("node_type") == "tech"
+ assert r.metadata.get("source") == "research"
+
+ def test_search_relevance_ranking(self, zvec_store):
+ """Test that search results have scores and are relevant."""
+ results = run(zvec_store.search(query="artificial intelligence", limit=5))
+ assert len(results) > 0
+ # All results should have a score
+ for r in results:
+ assert "score" in r.metadata
+ assert r.metadata["score"] > 0
+ # The top result should be highly relevant (AI content matches AI query)
+ top_content = results[0].content.lower()
+ assert "artificial intelligence" in top_content or "intelligence" in top_content or "ai" in top_content
+
+
+# ==================== Test: Get ====================
+
+
+class TestGet:
+ """Tests for retrieving nodes by ID."""
+
+ @pytest.fixture(autouse=True)
+ def _insert_sample_data(self, zvec_store):
+ """Insert sample data before each get test."""
+ nodes = create_sample_nodes("get")
+ run(zvec_store.insert(nodes))
+
+ def test_get_single_id(self, zvec_store):
+ """Test retrieving a single node by ID."""
+ result = run(zvec_store.get("get_node1"))
+ assert result is not None
+ assert result.vector_id == "get_node1"
+
+ def test_get_multiple_ids(self, zvec_store):
+ """Test retrieving multiple nodes by IDs."""
+ results = run(zvec_store.get(["get_node1", "get_node2"]))
+ assert isinstance(results, list)
+ assert len(results) >= 2
+ result_ids = {r.vector_id for r in results}
+ assert "get_node1" in result_ids
+ assert "get_node2" in result_ids
+
+ def test_get_nonexistent_id(self, zvec_store):
+ """Test retrieving a non-existent ID."""
+ result = run(zvec_store.get("nonexistent_id_xyz"))
+ assert result is None or result == []
+
+
+# ==================== Test: List ====================
+
+
+class TestList:
+ """Tests for listing nodes with optional filters and sorting."""
+
+ @pytest.fixture(autouse=True)
+ def _insert_sample_data(self, zvec_store):
+ """Insert sample data before each list test."""
+ nodes = create_sample_nodes("list")
+ run(zvec_store.insert(nodes))
+
+ def test_list_all(self, zvec_store):
+ """Test listing all nodes."""
+ results = run(zvec_store.list(limit=20))
+ assert len(results) > 0
+
+ def test_list_with_filter(self, zvec_store):
+ """Test listing nodes with metadata filter."""
+ results = run(zvec_store.list(filters={"category": "AI"}, limit=10))
+ for r in results:
+ assert r.metadata.get("category") == "AI"
+
+ def test_list_with_sorting(self, zvec_store):
+ """Test listing with sorting by metadata key."""
+ # Insert nodes with numeric metadata for sorting
+ sort_nodes = [
+ VectorNode(
+ vector_id=f"sort_{i}",
+ content=f"Sort test node {i}",
+ metadata={"rating": str(50 + i * 5), "test_type": "sort_test"},
+ )
+ for i in range(10)
+ ]
+ run(zvec_store.insert(sort_nodes))
+
+ results = run(
+ zvec_store.list(
+ filters={"test_type": "sort_test"},
+ sort_key="rating",
+ reverse=True,
+ limit=5,
+ ),
+ )
+ assert len(results) <= 5
+ # Verify descending order
+ ratings = [r.metadata.get("rating") for r in results]
+ for i in range(len(ratings) - 1):
+ assert ratings[i] >= ratings[i + 1]
+
+
+# ==================== Test: Update ====================
+
+
+class TestUpdate:
+ """Tests for updating existing nodes."""
+
+ @pytest.fixture(autouse=True)
+ def _insert_sample_data(self, zvec_store):
+ """Insert sample data before each update test."""
+ nodes = create_sample_nodes("upd")
+ run(zvec_store.insert(nodes))
+
+ def test_update_single_node(self, zvec_store):
+ """Test updating a single node's content and metadata."""
+ updated = VectorNode(
+ vector_id="upd_node2",
+ content="Machine learning is a powerful subset of AI that learns from data.",
+ metadata={
+ "node_type": "tech",
+ "category": "ML",
+ "updated": "true",
+ },
+ )
+ run(zvec_store.update(updated))
+
+ result = run(zvec_store.get("upd_node2"))
+ assert result is not None
+ assert result.metadata.get("updated") == "true"
+
+ def test_update_batch(self, zvec_store):
+ """Test batch updating multiple nodes."""
+ updates = [
+ VectorNode(
+ vector_id="upd_node1",
+ content="Updated content for node 1",
+ metadata={"node_type": "tech", "batch_updated": "true"},
+ ),
+ VectorNode(
+ vector_id="upd_node3",
+ content="Updated content for node 3",
+ metadata={"node_type": "tech_new", "batch_updated": "true"},
+ ),
+ ]
+ run(zvec_store.update(updates))
+
+ results = run(zvec_store.get(["upd_node1", "upd_node3"]))
+ if isinstance(results, list):
+ for r in results:
+ assert r.metadata.get("batch_updated") == "true"
+
+
+# ==================== Test: Delete ====================
+
+
+class TestDelete:
+ """Tests for deleting nodes."""
+
+ @pytest.fixture(autouse=True)
+ def _insert_sample_data(self, zvec_store):
+ """Insert sample data before each delete test."""
+ nodes = create_sample_nodes("del")
+ run(zvec_store.insert(nodes))
+
+ def test_delete_single(self, zvec_store):
+ """Test deleting a single node by ID."""
+ run(zvec_store.delete("del_node4"))
+
+ # Verify deletion
+ result = run(zvec_store.get("del_node4"))
+ assert result is None or result == []
+
+ def test_delete_batch(self, zvec_store):
+ """Test batch deleting multiple nodes by IDs."""
+ # First insert some extra nodes to delete
+ extra_nodes = [
+ VectorNode(
+ vector_id=f"del_extra_{i}",
+ content=f"Extra node {i} for batch delete test",
+ metadata={"test_type": "batch_delete"},
+ )
+ for i in range(5)
+ ]
+ run(zvec_store.insert(extra_nodes))
+
+ ids = [f"del_extra_{i}" for i in range(5)]
+ run(zvec_store.delete(ids))
+
+ # Verify all deleted
+ for nid in ids:
+ result = run(zvec_store.get(nid))
+ assert result is None or result == []
+
+ def test_delete_all(self, zvec_store):
+ """Test deleting all nodes from the collection."""
+ run(zvec_store.delete_all())
+ # Collection should be empty now
+ remaining = run(zvec_store.list(limit=100))
+ assert len(remaining) == 0
+
+
+# ==================== Test: Edge Cases ====================
+
+
+class TestEdgeCases:
+ """Tests for edge cases and boundary conditions."""
+
+ def test_empty_content(self, zvec_store):
+ """Test inserting a node with empty content."""
+ node = VectorNode(
+ vector_id="edge_empty",
+ content="",
+ metadata={"type": "empty"},
+ )
+ # Empty content may fail embedding — that's OK, we just want to see it handled
+ try:
+ run(zvec_store.insert([node]))
+ except Exception:
+ pass # Expected if embedding fails on empty string
+
+ def test_long_content(self, zvec_store):
+ """Test inserting a node with very long content."""
+ node = VectorNode(
+ vector_id="edge_long",
+ content="A" * 5000,
+ metadata={"type": "long_content"},
+ )
+ run(zvec_store.insert([node]))
+ result = run(zvec_store.get("edge_long"))
+ assert result is not None
+ assert len(result.content) == 5000
+
+ def test_special_characters(self, zvec_store):
+ """Test content with special characters."""
+ node = VectorNode(
+ vector_id="edge_special",
+ content="Special chars: @#$%^&*()[]{}|;:',.<>?/~`",
+ metadata={"type": "special_chars"},
+ )
+ run(zvec_store.insert([node]))
+ result = run(zvec_store.get("edge_special"))
+ assert result is not None
+ assert "@#$%" in result.content
+
+ def test_unicode_content(self, zvec_store):
+ """Test content with Unicode characters."""
+ node = VectorNode(
+ vector_id="edge_unicode",
+ content="Unicode test: 你好世界 مرحبا Привет",
+ metadata={"type": "unicode"},
+ )
+ run(zvec_store.insert([node]))
+ result = run(zvec_store.get("edge_unicode"))
+ assert result is not None
+ assert "你好世界" in result.content
+
+ def test_nonexistent_id(self, zvec_store):
+ """Test getting a non-existent ID."""
+ result = run(zvec_store.get("nonexistent_xyz_999"))
+ assert result is None or result == []
+
+ def test_metadata_with_empty_string_value(self, zvec_store):
+ """Test metadata containing empty string values."""
+ node = VectorNode(
+ vector_id="edge_meta_empty",
+ content="Testing empty metadata values",
+ metadata={"field1": "value1", "field2": "", "field3": "value3"},
+ )
+ run(zvec_store.insert([node]))
+ result = run(zvec_store.get("edge_meta_empty"))
+ assert result is not None
+
+ def test_search_nonexistent_filter(self, zvec_store):
+ """Test search with a filter value that doesn't match anything."""
+ nodes = create_sample_nodes("edge_filter")
+ run(zvec_store.insert(nodes))
+
+ results = run(
+ zvec_store.search(
+ query="test",
+ limit=10,
+ filters={"category": "NONEXISTENT_CATEGORY"},
+ ),
+ )
+ assert len(results) == 0
+
+
+# ==================== Test: Batch Operations ====================
+
+
+class TestBatchOperations:
+ """Tests for large-scale batch insert, update, and delete."""
+
+ def test_batch_insert_100_nodes(self, zvec_store):
+ """Test inserting 100 nodes in batch."""
+ batch_nodes = [
+ VectorNode(
+ vector_id=f"batch_{i}",
+ content=f"This is batch test content number {i} about technology and science.",
+ metadata={
+ "batch_id": str(i // 10),
+ "index": str(i),
+ "category": ["tech", "science", "business"][i % 3],
+ },
+ )
+ for i in range(100)
+ ]
+ run(zvec_store.insert(batch_nodes))
+
+ all_nodes = run(zvec_store.list(limit=150))
+ assert len(all_nodes) >= 100
+
+ def test_batch_update_20_nodes(self, zvec_store):
+ """Test batch updating 20 nodes."""
+ # Insert first
+ nodes = [
+ VectorNode(
+ vector_id=f"bupd_{i}",
+ content=f"Batch update test {i}",
+ metadata={"index": str(i)},
+ )
+ for i in range(30)
+ ]
+ run(zvec_store.insert(nodes))
+
+ # Update first 20
+ updates = [
+ VectorNode(
+ vector_id=f"bupd_{i}",
+ content=f"UPDATED content {i}",
+ metadata={"index": str(i), "updated": "true"},
+ )
+ for i in range(20)
+ ]
+ run(zvec_store.update(updates))
+
+ # Verify
+ results = run(zvec_store.list(filters={"updated": "true"}, limit=30))
+ assert len(results) >= 20
+
+ def test_batch_delete_50_nodes(self, zvec_store):
+ """Test batch deleting 50 nodes."""
+ # Insert
+ nodes = [
+ VectorNode(
+ vector_id=f"bdel_{i}",
+ content=f"Batch delete test {i}",
+ metadata={"index": str(i)},
+ )
+ for i in range(50)
+ ]
+ run(zvec_store.insert(nodes))
+
+ # Delete
+ ids = [f"bdel_{i}" for i in range(50)]
+ run(zvec_store.delete(ids))
+
+ # Verify
+ remaining = run(zvec_store.list(limit=200))
+ batch_remaining = [n for n in remaining if n.vector_id.startswith("bdel_")]
+ assert len(batch_remaining) == 0
+
+
+# ==================== Test: Concurrent Operations ====================
+
+
+class TestConcurrentOperations:
+ """Tests for concurrent read/write operations."""
+
+ def test_concurrent_inserts_and_searches(self, zvec_store):
+ """Test that concurrent inserts and searches work without errors."""
+
+ async def _run():
+ # Concurrent inserts
+ insert_tasks = []
+ for i in range(5):
+ batch = [
+ VectorNode(
+ vector_id=f"conc_{i}_{j}",
+ content=f"Concurrent test content {i}-{j}",
+ metadata={"thread_id": str(i)},
+ )
+ for j in range(10)
+ ]
+ insert_tasks.append(zvec_store.insert(batch))
+
+ await asyncio.gather(*insert_tasks)
+
+ # Concurrent searches
+ search_tasks = [zvec_store.search(query="concurrent test", limit=5) for _ in range(5)]
+ search_results = await asyncio.gather(*search_tasks)
+
+ # All searches should return results
+ for results in search_results:
+ assert len(results) > 0
+
+ run(_run())
+
+
+# ==================== Test: Data Model Conversion ====================
+
+
+class TestDataModelConversion:
+ """Tests for VectorNode <-> zvec Doc conversion helpers."""
+
+ def test_vector_node_to_doc_roundtrip(self, zvec_store):
+ """Test that VectorNode -> Doc -> VectorNode roundtrip preserves data."""
+ from reme.core.vector_store.zvec_vector_store import (
+ _vector_node_to_doc,
+ _doc_to_vector_node,
+ )
+
+ original = VectorNode(
+ vector_id="roundtrip_1",
+ content="Roundtrip test content",
+ vector=[0.1] * TestConfig.EMBEDDING_DIMENSION,
+ metadata={"key1": "value1", "key2": "42", "key3": "true"},
+ )
+
+ doc = _vector_node_to_doc(original)
+ assert doc.id == "roundtrip_1"
+ assert doc.field("content") == "Roundtrip test content"
+
+ restored = _doc_to_vector_node(doc, include_score=False)
+ assert restored.vector_id == "roundtrip_1"
+ assert restored.content == "Roundtrip test content"
+ assert restored.metadata.get("key1") == "value1"
+
+ def test_post_filter_exact_match(self):
+ """Test post-filtering with exact match."""
+ from reme.core.vector_store.zvec_vector_store import _apply_filters_post
+
+ nodes = [
+ VectorNode(vector_id="1", content="a", metadata={"category": "AI"}),
+ VectorNode(vector_id="2", content="b", metadata={"category": "ML"}),
+ VectorNode(vector_id="3", content="c", metadata={"category": "AI"}),
+ ]
+
+ filtered = _apply_filters_post(nodes, {"category": "AI"})
+ assert len(filtered) == 2
+ assert all(n.metadata["category"] == "AI" for n in filtered)
+
+ def test_post_filter_range_query(self):
+ """Test post-filtering with range query."""
+ from reme.core.vector_store.zvec_vector_store import _apply_filters_post
+
+ nodes = [
+ VectorNode(vector_id="1", content="a", metadata={"year": 2022}),
+ VectorNode(vector_id="2", content="b", metadata={"year": 2023}),
+ VectorNode(vector_id="3", content="c", metadata={"year": 2024}),
+ ]
+
+ filtered = _apply_filters_post(nodes, {"year": [2023, 2024]})
+ assert len(filtered) == 2
+
+ def test_post_filter_none_and_empty(self):
+ """Test post-filtering with None and empty filters."""
+ from reme.core.vector_store.zvec_vector_store import _apply_filters_post
+
+ nodes = [VectorNode(vector_id="1", content="a", metadata={})]
+
+ # None filter returns all
+ assert _apply_filters_post(nodes, None) == nodes
+ # Empty filter returns all
+ assert _apply_filters_post(nodes, {}) == nodes
+
+ def test_score_excluded_from_stored_metadata(self):
+ """Test that score is excluded when converting VectorNode to Doc."""
+ from reme.core.vector_store.zvec_vector_store import _vector_node_to_doc
+
+ node = VectorNode(
+ vector_id="score_test",
+ content="test",
+ vector=[0.1] * TestConfig.EMBEDDING_DIMENSION,
+ metadata={"key1": "val1", "score": 0.95},
+ )
+
+ doc = _vector_node_to_doc(node)
+ # The metadata JSON should NOT contain the score key
+ import json
+
+ stored_meta = json.loads(doc.field("metadata"))
+ assert "score" not in stored_meta
+ assert "key1" in stored_meta
+
+
+# ==================== Main Entry Point ====================
+
+
+async def run_standalone_tests():
+ """Run tests standalone (without pytest) for quick validation."""
+ tmp_dir = tempfile.mkdtemp(prefix="test_zvec_standalone_")
+ embedding_model = MockEmbeddingModel(dimension=TestConfig.EMBEDDING_DIMENSION)
+
+ store = ZvecVectorStore(
+ collection_name="standalone_test",
+ db_path=tmp_dir,
+ embedding_model=embedding_model,
+ dimension=TestConfig.EMBEDDING_DIMENSION,
+ distance="cosine",
+ )
+
+ try:
+ await store.start()
+ logger.info("✓ Store started")
+
+ # Insert
+ nodes = create_sample_nodes("std")
+ await store.insert(nodes)
+ logger.info(f"✓ Inserted {len(nodes)} nodes")
+
+ # Search
+ results = await store.search(query="artificial intelligence", limit=3)
+ logger.info(f"✓ Search returned {len(results)} results")
+ for r in results:
+ logger.info(f" - {r.vector_id}: {r.content[:50]}... (score={r.metadata.get('score')})")
+
+ # Get
+ result = await store.get("std_node1")
+ logger.info(f"✓ Get: {result.vector_id if result else 'None'}")
+
+ # List
+ all_nodes = await store.list(limit=10)
+ logger.info(f"✓ List: {len(all_nodes)} nodes")
+
+ # Update
+ await store.update(
+ VectorNode(
+ vector_id="std_node1",
+ content="Updated content",
+ metadata={"updated": "true"},
+ ),
+ )
+ result = await store.get("std_node1")
+ logger.info(f"✓ Update: metadata.updated={result.metadata.get('updated') if result else 'N/A'}")
+
+ # Delete
+ await store.delete("std_node4")
+ result = await store.get("std_node4")
+ logger.info(f"✓ Delete: {'gone' if result is None or result == [] else 'still exists'}")
+
+ # Count
+ count = await store.count()
+ logger.info(f"✓ Count: {count} nodes")
+
+ logger.info("✓ All standalone tests passed!")
+
+ finally:
+ await store.close()
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ asyncio.run(run_standalone_tests())
diff --git a/tests/vector/test_reme_vector.py b/tests/vector/test_reme_vector.py
index 3829c841..8a37cb97 100644
--- a/tests/vector/test_reme_vector.py
+++ b/tests/vector/test_reme_vector.py
@@ -20,7 +20,7 @@ async def main():
"dimensions": 1024,
},
default_vector_store_config={
- "backend": "local", # 支持 local/chroma/qdrant/elasticsearch
+ "backend": "local", # 支持 local/chroma/qdrant/elasticsearch/zvec
},
)
await reme.start()
From d72f5fc581b1e770f134dedf9e998d6f2aac907f Mon Sep 17 00:00:00 2001
From: yangtiancheng-ali
Date: Sat, 9 May 2026 10:30:37 +0800
Subject: [PATCH 06/16] feat(vector_store): add Hologres vector store
implementation (#226)
---
README.md | 2 +-
docs/index.md | 2 +-
docs/vector_store_api_guide.md | 52 +-
reme/core/vector_store/__init__.py | 3 +
reme/core/vector_store/hologres_store.py | 633 +++++++++++++++++++++++
tests/test_vector_store.py | 56 +-
6 files changed, 737 insertions(+), 11 deletions(-)
create mode 100644 reme/core/vector_store/hologres_store.py
diff --git a/README.md b/README.md
index b4bb48c4..b408332b 100644
--- a/README.md
+++ b/README.md
@@ -506,7 +506,7 @@ async def main():
"dimensions": 1024,
},
default_vector_store_config={
- "backend": "local", # Supports local/chroma/qdrant/elasticsearch/obvec/zvec
+ "backend": "local", # Supports local/chroma/qdrant/elasticsearch/obvec/zvec/hologres
},
)
await reme.start()
diff --git a/docs/index.md b/docs/index.md
index d7e2e716..d0e9d9d2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -139,7 +139,7 @@ response = requests.post("http://localhost:8002/retrieve_task_memory", json={
## 📚 Resources
- **[Installation Guide](installation.md)**, **[Quick Start](quick_start.md)**: Get started quickly with practical examples
-- **[Vector Storage Setup](vector_store_api_guide.md)**: Configure local, Elasticsearch, Qdrant, ChromaDB, or ObVec (OceanBase / seekdb via pyobvector) storage and usage
+- **[Vector Storage Setup](vector_store_api_guide.md)**: Configure local, Elasticsearch, Qdrant, ChromaDB, ObVec (OceanBase / seekdb via pyobvector) or Hologres storage and usage
- **[MCP Guide](mcp_quick_start.md)**: Create MCP services
- **[Personal Memory](personal_memory/personal_memory.md)**, **[Task Memory](task_memory/task_memory.md)** & **[Tool Memory](tool_memory/tool_memory.md)**: Operators used in personal memory, task memory and tool memory. You can modify the config to customize the pipelines.
- **[Example Collection](./cookbook/appworld/quickstart.md)**: Real use cases and best practices
diff --git a/docs/vector_store_api_guide.md b/docs/vector_store_api_guide.md
index 89881ef2..53ced358 100644
--- a/docs/vector_store_api_guide.md
+++ b/docs/vector_store_api_guide.md
@@ -34,6 +34,7 @@ FlowLLM provides multiple Vector Store implementations tailored to different use
- **ChromaVectorStore** ([source code](https://github.com/flowllm-ai/flowllm/blob/main/flowllm/core/vector_store/chroma_vector_store.py)): Based on ChromaDB, providing persistent storage and metadata filtering capabilities.
- **EsVectorStore** ([source code](https://github.com/flowllm-ai/flowllm/blob/main/flowllm/core/vector_store/es_vector_store.py)): Built on Elasticsearch, enabling powerful combined full-text and vector search functionalities.
- **ObVecVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/obvec_vector_store.py)): Uses [pyobvector](https://pypi.org/project/pyobvector/) against **OceanBase** or **seekdb** (MySQL-compatible wire protocol). Suitable when you already run OceanBase/seekdb or need a SQL-native vector table with HNSW-style ANN search and JSON metadata filters.
+- **HologresVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/hologres_store.py)): Uses [asyncpg](https://pypi.org/project/asyncpg/) against **Hologres** (PostgreSQL-compatible). Leverages native `float4[]` vector storage with built-in HGraph index for approximate nearest neighbor search. Suitable when you already run Hologres or need high-performance vector search with JSONB metadata filtering in a PostgreSQL-compatible environment.
- **ZvecVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/zvec_vector_store.py)): Built on zvec, a high-performance local vector database with strong-schema support and HNSW indexing. Suitable for single-machine deployments requiring fast vector search.
All Vector Store implementations inherit from **BaseVectorStore** ([source code](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/base_vector_store.py)) in ReMe, ensuring a consistent interface specification.
@@ -137,6 +138,20 @@ OBVEC_PASSWORD= python tests/test_vector_store.py --obvec
- **dimension**: Dimensionality of the embedding vectors (default: `1024`).
- **distance**: Distance metric — supports `cosine`, `l2`, `ip` (default: `cosine`).
+### HologresVectorStore Configuration
+
+- **host**: Hologres host address (default: `localhost`).
+- **port**: Hologres port (default: `80`).
+- **database**: Database name (default: `postgres`).
+- **user**: Database user (default: `postgres`).
+- **password**: Database password.
+- **schema**: PostgreSQL schema name (default: `public`).
+- **min_size**: Minimum connections in pool (default: `1`).
+- **max_size**: Maximum connections in pool (default: `10`).
+- **dsn**: Full DSN connection string. When provided, overrides `host`, `port`, `database`, `user`, and `password`.
+- **distance_method**: Distance method for the HGraph index: `Cosine`, `InnerProduct`, or `Euclidean` (default: `Cosine`).
+- **collection_name**: Table name for the collection (from `VectorStoreConfig`, default `reme`).
+
## Configuration File Examples
Configure Vector Store in `flowllm/config/default.yaml` under the `vector_store` section. The basic structure is as follows:
@@ -157,7 +172,7 @@ vector_store.default.params.=
### Configuration Field Descriptions
-- **`backend`** (required): Vector store backend type. Options: `local`, `memory`, `chroma`, `qdrant`, `elasticsearch`, `obvec`, `zvec`.
+- **`backend`** (required): Vector store backend type. Options: `local`, `memory`, `chroma`, `qdrant`, `elasticsearch`, `obvec`, `zvec`, `hologres`.
- **`embedding_model`** (required): Name of the embedding model configuration, referencing the `embedding_model` section.
- **`params`** (optional): Dictionary of backend-specific parameters passed to the vector store constructor.
@@ -353,7 +368,37 @@ vector_stores.default.password=your-root-password
ReMe service YAML uses the key `vector_stores` (plural); CLI overrides use the same nested paths.
-#### 7. ZvecVectorStore Configuration
+#### 7. HologresVectorStore Configuration
+
+**Implementation**: [`reme/core/vector_store/hologres_store.py`](https://github.com/agentscope-ai/ReMe/blob/main/reme/core/vector_store/hologres_store.py)
+
+**Example (Hologres instance)**:
+
+```yaml
+vector_stores:
+ default:
+ backend: hologres
+ embedding_model: default
+ collection_name: reme
+ host: "your-hologres-host"
+ port: 80
+ database: "postgres"
+ user: "postgres"
+ password: "your-password"
+ schema: "public"
+ distance_method: "Cosine"
+```
+
+```shell
+vector_stores.default.backend=hologres
+vector_stores.default.host=your-hologres-host
+vector_stores.default.port=80
+vector_stores.default.user=postgres
+vector_stores.default.password=your-password
+vector_stores.default.database=postgres
+```
+
+#### 8. ZvecVectorStore Configuration
Persistent local storage based on zvec with HNSW indexing and strong-schema support.
@@ -434,10 +479,11 @@ Two types of metadata filtering are supported:
- **Development & Testing**: Use MemoryVectorStore or LocalVectorStore—no additional services required.
- **Small-Scale Applications**: Use LocalVectorStore or ChromaVectorStore for simplicity and ease of use.
-- **Production Environments**: Use QdrantVectorStore, EsVectorStore, or ObVecVectorStore (OceanBase/seekdb) for high performance and scalability, depending on your existing infrastructure.
+- **Production Environments**: Use QdrantVectorStore, EsVectorStore, ObVecVectorStore (OceanBase/seekdb), or HologresVectorStore for high performance and scalability, depending on your existing infrastructure.
- **High-Performance Local Search**: Use ZvecVectorStore for single-machine deployments requiring fast HNSW-based vector search with local persistence.
- **Hybrid Search**: Use EsVectorStore to combine vector search with full-text search capabilities.
- **OceanBase / seekdb**: Use ObVecVectorStore when you standardize on pyobvector and SQL-accessible vector tables.
+- **Hologres**: Use HologresVectorStore when you run Hologres and need native HGraph-indexed vector search with PostgreSQL-compatible SQL and JSONB metadata filtering.
## Important Notes
diff --git a/reme/core/vector_store/__init__.py b/reme/core/vector_store/__init__.py
index 0426fd64..7b5a60bb 100644
--- a/reme/core/vector_store/__init__.py
+++ b/reme/core/vector_store/__init__.py
@@ -3,6 +3,7 @@
from .base_vector_store import BaseVectorStore
from .chroma_vector_store import ChromaVectorStore
from .es_vector_store import ESVectorStore
+from .hologres_store import HologresVectorStore
from .local_vector_store import LocalVectorStore
from .obvec_vector_store import ObVecVectorStore
from .pgvector_store import PGVectorStore
@@ -14,6 +15,7 @@ __all__ = [
"BaseVectorStore",
"ChromaVectorStore",
"ESVectorStore",
+ "HologresVectorStore",
"LocalVectorStore",
"ObVecVectorStore",
"PGVectorStore",
@@ -23,6 +25,7 @@ __all__ = [
R.vector_stores.register("chroma")(ChromaVectorStore)
R.vector_stores.register("es")(ESVectorStore)
+R.vector_stores.register("hologres")(HologresVectorStore)
R.vector_stores.register("local")(LocalVectorStore)
R.vector_stores.register("obvec")(ObVecVectorStore)
R.vector_stores.register("pgvector")(PGVectorStore)
diff --git a/reme/core/vector_store/hologres_store.py b/reme/core/vector_store/hologres_store.py
new file mode 100644
index 00000000..270dc1bf
--- /dev/null
+++ b/reme/core/vector_store/hologres_store.py
@@ -0,0 +1,633 @@
+"""Hologres implementation for vector storage and retrieval."""
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+
+from .base_vector_store import BaseVectorStore
+from ..embedding import BaseEmbeddingModel
+from ..schema import VectorNode
+
+_ASYNCPG_IMPORT_ERROR: Exception | None = None
+
+try:
+ import asyncpg
+ from asyncpg import Pool
+except Exception as e:
+ _ASYNCPG_IMPORT_ERROR = e
+ asyncpg = None
+ Pool = None
+
+
+class HologresVectorStore(BaseVectorStore):
+ """Vector store implementation using Hologres for efficient similarity search.
+
+ Hologres uses native float4[] arrays for vector storage with built-in
+ HGraph index for approximate nearest neighbor search, unlike pgvector
+ which requires an extension.
+ """
+
+ @staticmethod
+ def _validate_table_name(name: str) -> None:
+ """Validate table name to prevent SQL injection."""
+ if not name:
+ raise ValueError("Table name cannot be empty")
+ if len(name) > 63:
+ raise ValueError(f"Table name too long: {len(name)} characters (max 63)")
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
+ raise ValueError(
+ f"Invalid table name: {name}. Must start with letter or underscore, "
+ "and contain only alphanumeric characters and underscores.",
+ )
+
+ def __init__(
+ self,
+ collection_name: str,
+ db_path: str | Path,
+ embedding_model: BaseEmbeddingModel,
+ host: str = "localhost",
+ port: int = 80,
+ database: str = "postgres",
+ user: str = "postgres",
+ password: str = "",
+ schema: str = "public",
+ min_size: int = 1,
+ max_size: int = 10,
+ dsn: str | None = None,
+ distance_method: str = "Cosine",
+ **kwargs,
+ ):
+ """Initialize the Hologres vector store with connection parameters.
+
+ Args:
+ collection_name: Name of the collection (table).
+ db_path: Database path (used by base class).
+ embedding_model: Embedding model for generating vectors.
+ host: Hologres host address.
+ port: Hologres port (default 80 for Hologres).
+ database: Database name.
+ user: Database user.
+ password: Database password.
+ schema: PostgreSQL schema name (default "public").
+ min_size: Minimum connections in pool.
+ max_size: Maximum connections in pool.
+ dsn: Full DSN connection string (overrides individual params).
+ distance_method: Distance method for HGraph index (Cosine, InnerProduct, Euclidean).
+ """
+ if _ASYNCPG_IMPORT_ERROR is not None:
+ raise ImportError(
+ "Hologres vector store requires asyncpg. Install with `pip install asyncpg`",
+ ) from _ASYNCPG_IMPORT_ERROR
+
+ self._validate_table_name(collection_name)
+ self._validate_table_name(schema)
+
+ super().__init__(
+ collection_name=collection_name,
+ db_path=db_path,
+ embedding_model=embedding_model,
+ **kwargs,
+ )
+
+ self.dsn = dsn
+ self.host = host
+ self.port = port
+ self.database = database
+ self.user = user
+ self.password = password
+ self.schema = schema
+ self.min_size = min_size
+ self.max_size = max_size
+ self.distance_method = distance_method
+ self._pool: Pool | None = None
+ self.embedding_model_dims = embedding_model.dimensions
+
+ @property
+ def _qualified_name(self) -> str:
+ """Return the schema-qualified table name (e.g. 'my_schema.my_table')."""
+ return f"{self.schema}.{self.collection_name}"
+
+ def _qualify(self, table_name: str) -> str:
+ """Return a schema-qualified name for an arbitrary table."""
+ return f"{self.schema}.{table_name}"
+
+ @staticmethod
+ async def _hologres_reset(conn):
+ """Custom reset for Hologres connections."""
+ await conn.execute(
+ """
+ SELECT pg_advisory_unlock_all();
+ CLOSE ALL;
+ RESET ALL;
+ """,
+ )
+
+ async def _get_pool(self) -> Pool:
+ """Create or return the existing asyncpg connection pool."""
+ if self._pool is None:
+ if self.dsn:
+ self._pool = await asyncpg.create_pool(
+ dsn=self.dsn,
+ min_size=self.min_size,
+ max_size=self.max_size,
+ reset=self._hologres_reset,
+ )
+ else:
+ self._pool = await asyncpg.create_pool(
+ host=self.host,
+ port=self.port,
+ database=self.database,
+ user=self.user,
+ password=self.password,
+ min_size=self.min_size,
+ max_size=self.max_size,
+ reset=self._hologres_reset,
+ )
+
+ # Ensure schema exists
+ async with self._pool.acquire() as conn:
+ await conn.execute(f"CREATE SCHEMA IF NOT EXISTS {self.schema}")
+
+ logger.info(f"Hologres connection pool created for database {self.database}")
+
+ return self._pool
+
+ @staticmethod
+ def _vector_to_pg_array(vector: list[float]) -> str:
+ """Convert a Python list of floats to PostgreSQL array literal format."""
+ return "{" + ",".join(map(str, vector)) + "}"
+
+ @staticmethod
+ def _pg_array_to_vector(pg_array) -> list[float] | None:
+ """Convert a PostgreSQL array result to a Python list of floats."""
+ if pg_array is None:
+ return None
+ if isinstance(pg_array, list):
+ return [float(x) for x in pg_array]
+ # Handle string format like {1.0,2.0,3.0}
+ raw = str(pg_array)
+ if raw.startswith("{") and raw.endswith("}"):
+ return [float(x) for x in raw[1:-1].split(",")]
+ return None
+
+ async def list_collections(self) -> list[str]:
+ """List all available table names in the current schema."""
+ pool = await self._get_pool()
+ async with pool.acquire() as conn:
+ rows = await conn.fetch(
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = $1",
+ self.schema,
+ )
+ return [row["table_name"] for row in rows]
+
+ async def create_collection(self, collection_name: str, **kwargs):
+ """Create a new Hologres table with vector support and HGraph index."""
+ self._validate_table_name(collection_name)
+ pool = await self._get_pool()
+ dimensions = kwargs.get("dimensions", self.embedding_model_dims)
+ qualified = self._qualify(collection_name)
+
+ async with pool.acquire() as conn:
+ create_sql = f"""
+ CREATE TABLE IF NOT EXISTS {qualified} (
+ id TEXT PRIMARY KEY,
+ content TEXT,
+ vector float4[] CHECK (array_ndims(vector) = 1 AND array_length(vector, 1) = {dimensions}),
+ metadata JSONB
+ )
+ WITH (
+ vectors = '{{
+ "vector": {{
+ "algorithm": "HGraph",
+ "distance_method": "{self.distance_method}",
+ "builder_params": {{
+ "base_quantization_type": "rabitq",
+ "rabitq_use_fht":true,
+ "graph_storage_type": "compressed",
+ "max_total_size_to_merge_mb": 4096,
+ "max_degree": 64,
+ "ef_construction": 400,
+ "precise_quantization_type": "fp32",
+ "use_reorder": true
+ }}
+ }}
+ }}'
+ )
+ """
+ await conn.execute(create_sql)
+
+ logger.info(f"Created Hologres collection {qualified} with dimensions={dimensions}")
+
+ async def delete_collection(self, collection_name: str, **kwargs):
+ """Remove the specified collection table from the database."""
+ self._validate_table_name(collection_name)
+ pool = await self._get_pool()
+ qualified = self._qualify(collection_name)
+ async with pool.acquire() as conn:
+ await conn.execute(f"DROP TABLE IF EXISTS {qualified}")
+ logger.info(f"Deleted collection {qualified}")
+
+ async def copy_collection(self, collection_name: str, **kwargs):
+ """Duplicate the structure and content of the current collection to a new table."""
+ self._validate_table_name(collection_name)
+ pool = await self._get_pool()
+ qualified_src = self._qualified_name
+ qualified_dst = self._qualify(collection_name)
+
+ async with pool.acquire() as conn:
+ columns = await conn.fetch(
+ """
+ SELECT column_name, data_type, udt_name
+ FROM information_schema.columns
+ WHERE table_name = $1 AND table_schema = $2
+ """,
+ self.collection_name,
+ self.schema,
+ )
+
+ if not columns:
+ raise ValueError(f"Source collection {qualified_src} does not exist")
+
+ # Create new table with primary key, then add data
+ await conn.execute(
+ f"""
+ SET hg_experimental_enable_create_table_like_properties = true;
+ CALL hg_create_table_like('{qualified_dst}', 'select * from {qualified_src}')
+ """,
+ )
+ await conn.execute(f"INSERT INTO {qualified_dst} SELECT * FROM {qualified_src} ;")
+
+ logger.info(f"Copied collection {qualified_src} to {qualified_dst}")
+
+ async def insert(self, nodes: VectorNode | list[VectorNode], **kwargs):
+ """Insert or upsert vector nodes into the Hologres collection."""
+ if isinstance(nodes, VectorNode):
+ nodes = [nodes]
+
+ if not nodes:
+ return
+
+ nodes_without_vectors = [node for node in nodes if node.vector is None]
+ if nodes_without_vectors:
+ nodes_with_vectors = await self.get_node_embeddings(nodes_without_vectors)
+ vector_map = {n.vector_id: n for n in nodes_with_vectors}
+ nodes_to_insert = [vector_map.get(n.vector_id, n) if n.vector is None else n for n in nodes]
+ else:
+ nodes_to_insert = nodes
+
+ pool = await self._get_pool()
+ data = [
+ (
+ node.vector_id,
+ node.content,
+ node.vector,
+ json.dumps(node.metadata),
+ )
+ for node in nodes_to_insert
+ ]
+
+ async with pool.acquire() as conn:
+ on_conflict = kwargs.get("on_conflict", "update")
+
+ if on_conflict == "update":
+ await conn.executemany(
+ f"""
+ INSERT INTO {self._qualified_name} (id, content, vector, metadata)
+ VALUES ($1, $2, $3::float4[], $4::jsonb)
+ ON CONFLICT (id) DO UPDATE SET
+ content = EXCLUDED.content,
+ vector = EXCLUDED.vector,
+ metadata = EXCLUDED.metadata
+ """,
+ data,
+ )
+ elif on_conflict == "ignore":
+ await conn.executemany(
+ f"""
+ INSERT INTO {self._qualified_name} (id, content, vector, metadata)
+ VALUES ($1, $2, $3::float4[], $4::jsonb)
+ ON CONFLICT (id) DO NOTHING
+ """,
+ data,
+ )
+ else:
+ await conn.executemany(
+ f"""
+ INSERT INTO {self._qualified_name} (id, content, vector, metadata)
+ VALUES ($1, $2, $3::float4[], $4::jsonb)
+ """,
+ data,
+ )
+
+ logger.info(f"Inserted {len(nodes_to_insert)} documents into {self._qualified_name}")
+
+ @staticmethod
+ def _build_filter_clause(filters: dict | None) -> tuple[str, list]:
+ """Generate an SQL WHERE clause and parameter list from a filter dictionary.
+
+ Supports two filter formats:
+ 1. Range query: {"field": [start_value, end_value]}
+ 2. Exact match: {"field": value}
+ """
+ if not filters:
+ return "", []
+
+ conditions = []
+ params = []
+ param_idx = 1
+
+ for key, value in filters.items():
+ if not key.replace("_", "").replace(".", "").isalnum():
+ raise ValueError(
+ f"Invalid metadata key: {key}. Only alphanumeric characters, underscore and dot are allowed.",
+ )
+
+ if isinstance(value, list) and len(value) == 2:
+ if isinstance(value[0], (int, float)) and isinstance(value[1], (int, float)):
+ conditions.append(
+ f"(metadata->>'{key}')::numeric >= ${param_idx} AND "
+ f"(metadata->>'{key}')::numeric <= ${param_idx + 1}",
+ )
+ else:
+ conditions.append(f"metadata->>'{key}' >= ${param_idx} AND metadata->>'{key}' <= ${param_idx + 1}")
+ params.extend([value[0], value[1]])
+ param_idx += 2
+ else:
+ conditions.append(f"metadata->>'{key}' = ${param_idx}")
+ params.append(str(value))
+ param_idx += 1
+
+ filter_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
+ return filter_clause, params
+
+ async def search(
+ self,
+ query: str,
+ limit: int = 5,
+ filters: dict | None = None,
+ **kwargs,
+ ) -> list[VectorNode]:
+ """Perform vector similarity search using Hologres approx_cosine_distance."""
+ query_vector = await self.get_embedding(query)
+ vector_str = self._vector_to_pg_array(query_vector)
+ pool = await self._get_pool()
+
+ filter_clause, filter_params = self._build_filter_clause(filters)
+
+ # filter_params use $1..$N, limit uses $(N+1)
+ limit_placeholder = f"${len(filter_params) + 1}"
+
+ async with pool.acquire() as conn:
+ sql = f"""
+ SELECT id, content, vector, metadata,
+ approx_cosine_distance(vector, '{vector_str}') AS distance
+ FROM {self._qualified_name}
+ {filter_clause}
+ ORDER BY distance DESC
+ LIMIT {limit_placeholder}
+ """
+ rows = await conn.fetch(sql, *filter_params, limit)
+
+ results = []
+ score_threshold = kwargs.get("score_threshold")
+
+ for row in rows:
+ distance = float(row["distance"])
+ # approx_cosine_distance returns cosine similarity (higher = more similar)
+ score = distance
+ if score_threshold is not None and score < score_threshold:
+ continue
+
+ vector_data = self._pg_array_to_vector(row["vector"])
+
+ metadata = row["metadata"] if row["metadata"] else {}
+ if isinstance(metadata, str):
+ metadata = json.loads(metadata)
+
+ metadata["score"] = score
+ metadata["_distance"] = 1 - score
+
+ node = VectorNode(
+ vector_id=row["id"],
+ content=row["content"] or "",
+ vector=vector_data,
+ metadata=metadata,
+ )
+ results.append(node)
+
+ return results
+
+ async def delete(self, vector_ids: str | list[str], **kwargs):
+ """Remove specific vector records from the collection by their IDs."""
+ if isinstance(vector_ids, str):
+ vector_ids = [vector_ids]
+
+ if not vector_ids:
+ return
+
+ pool = await self._get_pool()
+ async with pool.acquire() as conn:
+ placeholders = ", ".join([f"${i + 1}" for i in range(len(vector_ids))])
+ await conn.execute(
+ f"DELETE FROM {self._qualified_name} WHERE id IN ({placeholders})",
+ *vector_ids,
+ )
+
+ logger.info(f"Deleted {len(vector_ids)} documents from {self._qualified_name}")
+
+ async def delete_all(self, **kwargs):
+ """Remove all vectors from the collection."""
+ pool = await self._get_pool()
+ async with pool.acquire() as conn:
+ result = await conn.execute(f"DELETE FROM {self._qualified_name}")
+
+ logger.info(f"Deleted all documents from {self._qualified_name} result={result}")
+
+ async def update(self, nodes: VectorNode | list[VectorNode], **kwargs):
+ """Update existing vector nodes with new content, embeddings, or metadata."""
+ if isinstance(nodes, VectorNode):
+ nodes = [nodes]
+
+ if not nodes:
+ return
+
+ nodes_without_vectors = [node for node in nodes if node.vector is None and node.content]
+ if nodes_without_vectors:
+ nodes_with_vectors = await self.get_node_embeddings(nodes_without_vectors)
+ vector_map = {n.vector_id: n for n in nodes_with_vectors}
+ nodes_to_update = [vector_map.get(n.vector_id, n) if n.vector is None and n.content else n for n in nodes]
+ else:
+ nodes_to_update = nodes
+
+ pool = await self._get_pool()
+ async with pool.acquire() as conn:
+ for node in nodes_to_update:
+ update_fields = []
+ params = []
+ idx = 1
+
+ if node.content:
+ update_fields.append(f"content = ${idx}")
+ params.append(node.content)
+ idx += 1
+
+ if node.vector:
+ update_fields.append(f"vector = ${idx}::float4[]")
+ params.append(node.vector)
+ idx += 1
+
+ if node.metadata:
+ update_fields.append(f"metadata = ${idx}::jsonb")
+ params.append(json.dumps(node.metadata))
+ idx += 1
+
+ if update_fields:
+ params.append(node.vector_id)
+ await conn.execute(
+ f"UPDATE {self._qualified_name} SET {', '.join(update_fields)} WHERE id = ${idx}",
+ *params,
+ )
+
+ logger.info(f"Updated {len(nodes_to_update)} documents in {self._qualified_name}")
+
+ async def get(self, vector_ids: str | list[str]) -> VectorNode | list[VectorNode] | None:
+ """Retrieve vector nodes by their unique identifiers."""
+ single_result = isinstance(vector_ids, str)
+ if single_result:
+ vector_ids = [vector_ids]
+
+ if not vector_ids:
+ return [] if not single_result else None
+
+ pool = await self._get_pool()
+ async with pool.acquire() as conn:
+ placeholders = ", ".join([f"${i + 1}" for i in range(len(vector_ids))])
+ rows = await conn.fetch(
+ f"SELECT id, content, vector, metadata FROM {self._qualified_name} WHERE id IN ({placeholders})",
+ *vector_ids,
+ )
+
+ results = []
+ for row in rows:
+ vector_data = self._pg_array_to_vector(row["vector"])
+
+ metadata = row["metadata"] if row["metadata"] else {}
+ if isinstance(metadata, str):
+ metadata = json.loads(metadata)
+
+ results.append(
+ VectorNode(
+ vector_id=row["id"],
+ content=row["content"] or "",
+ vector=vector_data,
+ metadata=metadata,
+ ),
+ )
+
+ if single_result:
+ return results[0] if results else None
+ return results
+
+ async def list(
+ self,
+ filters: dict | None = None,
+ limit: int | None = None,
+ sort_key: str | None = None,
+ reverse: bool = False,
+ ) -> list[VectorNode]:
+ """Return a list of vector nodes matching the provided filters and limit.
+
+ Args:
+ filters: Dictionary of filter conditions to match vectors
+ limit: Maximum number of vectors to return
+ sort_key: Key to sort the results by (e.g., field name in metadata). None for no sorting
+ reverse: If True, sort in descending order; if False, sort in ascending order
+ """
+ pool = await self._get_pool()
+ filter_clause, filter_params = self._build_filter_clause(filters)
+
+ order_clause = ""
+ if sort_key:
+ order_direction = "DESC" if reverse else "ASC"
+ order_clause = f"ORDER BY metadata->>'{sort_key}' {order_direction}"
+
+ limit_clause = ""
+ if limit:
+ limit_clause = f"LIMIT ${len(filter_params) + 1}"
+ filter_params.append(limit)
+
+ async with pool.acquire() as conn:
+ sql = f"""
+ SELECT id, content, vector, metadata
+ FROM {self._qualified_name}
+ {filter_clause}
+ {order_clause}
+ {limit_clause}
+ """
+ rows = await conn.fetch(sql, *filter_params)
+
+ results = []
+ for row in rows:
+ vector_data = self._pg_array_to_vector(row["vector"])
+
+ metadata = row["metadata"] if row["metadata"] else {}
+ if isinstance(metadata, str):
+ metadata = json.loads(metadata)
+
+ results.append(
+ VectorNode(
+ vector_id=row["id"],
+ content=row["content"] or "",
+ vector=vector_data,
+ metadata=metadata,
+ ),
+ )
+
+ return results
+
+ async def collection_info(self) -> dict[str, Any]:
+ """Fetch metadata including record count and disk usage for the collection."""
+ pool = await self._get_pool()
+ qualified = self._qualified_name
+
+ async with pool.acquire() as conn:
+ count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified}")
+ size = await conn.fetchval(f"SELECT pg_size_pretty(pg_total_relation_size('{qualified}'))")
+
+ return {
+ "name": qualified,
+ "count": count,
+ "size": size,
+ }
+
+ async def reset(self):
+ """Purge all data by dropping and recreating the collection table."""
+ logger.warning(f"Resetting collection {self._qualified_name}...")
+ await self.delete_collection(self.collection_name)
+ await self.create_collection(self.collection_name)
+
+ async def reset_collection(self, collection_name: str):
+ """Reset collection with table name validation."""
+ self._validate_table_name(collection_name)
+ self.collection_name = collection_name
+ await self.create_collection(collection_name)
+ logger.info(f"Collection reset to {self._qualified_name}")
+
+ async def start(self) -> None:
+ """Initialize the PGVector store.
+
+ Creates the connection pool and ensures the collection table exists.
+ """
+ await self._get_pool()
+ await super().start()
+ logger.info(f"Hologres collection {self._qualified_name} initialized")
+
+ async def close(self):
+ """Terminate the database connection pool."""
+ if self._pool is not None:
+ await self._pool.close()
+ self._pool = None
+ logger.info("Hologres connection pool closed")
diff --git a/tests/test_vector_store.py b/tests/test_vector_store.py
index 9b39ab9b..346be967 100644
--- a/tests/test_vector_store.py
+++ b/tests/test_vector_store.py
@@ -2,8 +2,8 @@
"""Unified test suite for vector store implementations.
This module provides comprehensive test coverage for LocalVectorStore, ESVectorStore,
-PGVectorStore, QdrantVectorStore, ChromaVectorStore, ObVecVectorStore and ZvecVectorStore implementations.
-Tests can be run for specific vector stores or all implementations.
+PGVectorStore, QdrantVectorStore, ChromaVectorStore, ObVecVectorStore, HologresVectorStore, and
+ZvecVectorStore implementations. Tests can be run for specific vector stores or all implementations.
Usage:
python test_vector_store.py --local # Test LocalVectorStore only
@@ -12,6 +12,7 @@ Usage:
python test_vector_store.py --qdrant # Test QdrantVectorStore only
python test_vector_store.py --chroma # Test ChromaVectorStore only
python test_vector_store.py --obvec # Test ObVecVectorStore only (needs seekdb / OceanBase)
+ python test_vector_store.py --hologres # Test HologresVectorStore only
python test_vector_store.py --zvec # Test ZvecVectorStore only
python test_vector_store.py --all # Test all vector stores
"""
@@ -32,6 +33,7 @@ from reme.core.utils import load_env, cosine_similarity
from reme.core.vector_store import (
BaseVectorStore,
ChromaVectorStore,
+ HologresVectorStore,
LocalVectorStore,
ESVectorStore,
ObVecVectorStore,
@@ -92,6 +94,19 @@ class TestConfig:
OBVEC_PASSWORD = os.environ.get("OBVEC_PASSWORD", "root")
OBVEC_DATABASE = os.environ.get("OBVEC_DATABASE", "test")
+ # HologresVectorStore settings
+ HOLOGRES_DSN = os.environ.get(
+ "HOLOGRES_DSN",
+ "",
+ ) # Full DSN connection string (overrides host/port/database/user/password)
+ HOLOGRES_HOST = os.environ.get("HOLOGRES_HOST", "localhost")
+ HOLOGRES_PORT = int(os.environ.get("HOLOGRES_PORT", "80"))
+ HOLOGRES_DATABASE = os.environ.get("HOLOGRES_DATABASE", "postgres")
+ HOLOGRES_USER = os.environ.get("HOLOGRES_USER", "postgres")
+ HOLOGRES_PASSWORD = os.environ.get("HOLOGRES_PASSWORD", "")
+ HOLOGRES_SCHEMA = os.environ.get("HOLOGRES_SCHEMA", "public")
+ HOLOGRES_MIN_SIZE = 1
+ HOLOGRES_MAX_SIZE = 5
# ZvecVectorStore settings
ZVEC_PATH = "./test_vector_store_zvec" # For local persistent mode
@@ -205,7 +220,7 @@ def get_store_type(store: BaseVectorStore) -> str:
store: Vector store instance
Returns:
- str: Type identifier ("local", "es", "pgvector", "qdrant", "chroma", "obvec", or "zvec")
+ str: Type identifier ("local", "es", "pgvector", "qdrant", "chroma", "obvec", "zvec", or "hologres")
"""
if isinstance(store, LocalVectorStore):
return "local"
@@ -221,6 +236,8 @@ def get_store_type(store: BaseVectorStore) -> str:
return "obvec"
elif isinstance(store, ZvecVectorStore):
return "zvec"
+ elif isinstance(store, HologresVectorStore):
+ return "hologres"
else:
raise ValueError(f"Unknown vector store type: {type(store)}")
@@ -230,7 +247,7 @@ def create_vector_store(store_type: str, collection_name: str) -> BaseVectorStor
"""Create a vector store instance based on type.
Args:
- store_type: Type of vector store ("local", "es", "pgvector", "qdrant", "chroma", or "obvec")
+ store_type: Type of vector store ("local", "es", "pgvector", "qdrant", "chroma", "obvec", or "hologres")
collection_name: Name of the collection
Returns:
@@ -312,6 +329,23 @@ def create_vector_store(store_type: str, collection_name: str) -> BaseVectorStor
dimension=config.EMBEDDING_DIMENSIONS,
distance="cosine",
)
+ elif store_type == "hologres":
+ kwargs = {
+ "collection_name": collection_name,
+ "embedding_model": embedding_model,
+ "db_path": tempfile.mkdtemp(prefix="test_hologres_"),
+ "host": config.HOLOGRES_HOST,
+ "port": config.HOLOGRES_PORT,
+ "database": config.HOLOGRES_DATABASE,
+ "user": config.HOLOGRES_USER,
+ "password": config.HOLOGRES_PASSWORD,
+ "schema": config.HOLOGRES_SCHEMA,
+ "min_size": config.HOLOGRES_MIN_SIZE,
+ "max_size": config.HOLOGRES_MAX_SIZE,
+ }
+ if config.HOLOGRES_DSN:
+ kwargs["dsn"] = config.HOLOGRES_DSN
+ return HologresVectorStore(**kwargs)
else:
raise ValueError(f"Unknown store type: {store_type}")
@@ -631,7 +665,7 @@ async def test_copy_collection(store: BaseVectorStore, store_name: str):
# Elasticsearch, PostgreSQL and OceanBase require lowercase table/index names
store_type = get_store_type(store)
- if store_type in ("es", "pgvector", "obvec"):
+ if store_type in ("es", "pgvector", "obvec", "hologres"):
copy_collection_name = copy_collection_name.lower()
# Clean up if exists
@@ -1835,6 +1869,7 @@ Examples:
python test_vector_store.py --qdrant # Test QdrantVectorStore only
python test_vector_store.py --chroma # Test ChromaVectorStore only
python test_vector_store.py --obvec # Test ObVecVectorStore (seekdb / OceanBase)
+ python test_vector_store.py --hologres # Test HologresVectorStore
python test_vector_store.py --all # Test all vector stores
""",
)
@@ -1868,6 +1903,11 @@ Examples:
action="store_true",
help="Test ObVecVectorStore",
)
+ parser.add_argument(
+ "--hologres",
+ action="store_true",
+ help="Test HologresVectorStore",
+ )
parser.add_argument(
"--zvec",
action="store_true",
@@ -1892,6 +1932,7 @@ Examples:
("qdrant", "QdrantVectorStore"),
("chroma", "ChromaVectorStore"),
("obvec", "ObVecVectorStore"),
+ ("hologres", "HologresVectorStore"),
("zvec", "ZvecVectorStore"),
]
else:
@@ -1908,6 +1949,8 @@ Examples:
stores_to_test.append(("chroma", "ChromaVectorStore"))
if args.obvec:
stores_to_test.append(("obvec", "ObVecVectorStore"))
+ if args.hologres:
+ stores_to_test.append(("hologres", "HologresVectorStore"))
if args.zvec:
stores_to_test.append(("zvec", "ZvecVectorStore"))
@@ -1920,11 +1963,12 @@ Examples:
("qdrant", "QdrantVectorStore"),
("chroma", "ChromaVectorStore"),
("obvec", "ObVecVectorStore"),
+ ("hologres", "HologresVectorStore"),
("zvec", "ZvecVectorStore"),
]
print("No vector store specified, defaulting to test all vector stores")
print(
- "Use --local/--es/--pgvector/--qdrant/--chroma/--obvec/--zvec to test specific ones\n",
+ "Use --local/--es/--pgvector/--qdrant/--chroma/--obvec/--zvec/--hologres to test specific ones\n",
)
# Run tests for each vector store
From ccadf1d3f9dd06ac05438f98acb5eb5f6dbcdb77 Mon Sep 17 00:00:00 2001
From: Aqil Aziz
Date: Thu, 14 May 2026 13:37:17 +0700
Subject: [PATCH 07/16] fix(file_watcher): reset stop event on restart (#233)
---
reme/core/file_watcher/base_file_watcher.py | 6 ++-
tests/test_base_file_watcher.py | 58 ++++++++++++---------
2 files changed, 37 insertions(+), 27 deletions(-)
diff --git a/reme/core/file_watcher/base_file_watcher.py b/reme/core/file_watcher/base_file_watcher.py
index b4644dd7..4269117c 100644
--- a/reme/core/file_watcher/base_file_watcher.py
+++ b/reme/core/file_watcher/base_file_watcher.py
@@ -76,12 +76,14 @@ class BaseFileWatcher:
if self._running:
return
+ self._stop_event = asyncio.Event()
self._running = True
async def _initialize_and_watch():
if self.rebuild_index_on_start:
- await self.file_store.clear_all()
- logger.info("Cleared all indexed data on start")
+ if self.file_store is not None:
+ await self.file_store.clear_all()
+ logger.info("Cleared all indexed data on start")
await self._scan_existing_files()
await self._watch_loop()
diff --git a/tests/test_base_file_watcher.py b/tests/test_base_file_watcher.py
index 234d03c2..dcf56abf 100644
--- a/tests/test_base_file_watcher.py
+++ b/tests/test_base_file_watcher.py
@@ -79,6 +79,15 @@ def temp_nested_dir(temp_dir: Path):
yield temp_dir
+def make_mock_file_store():
+ """Create an async-compatible mock file store."""
+ mock_file_store = MagicMock()
+ mock_file_store.clear_all = AsyncMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ return mock_file_store
+
+
# ==================== Test Existing Paths ====================
@@ -147,6 +156,21 @@ class TestExistingPaths:
await watcher.close()
+ @pytest.mark.asyncio
+ async def test_restart_resets_stop_event(self, temp_dir: Path):
+ """Test restarting watcher resets the previous stop signal."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ await watcher.start()
+ await watcher.close()
+
+ assert watcher._stop_event.is_set() is True
+
+ await watcher.start()
+ assert watcher._stop_event.is_set() is False
+
+ await watcher.close()
+
@pytest.mark.asyncio
async def test_multiple_start_calls(self, temp_dir: Path):
"""Test that multiple start calls don't create multiple tasks."""
@@ -381,9 +405,7 @@ class TestRebuildIndexOnStart:
callback_called.append(changes)
# Create mock file_store
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths=str(temp_dir),
@@ -408,9 +430,7 @@ class TestRebuildIndexOnStart:
callback_called.append(changes)
# Create mock file_store
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths=str(temp_dir),
@@ -441,9 +461,7 @@ class TestRebuildIndexOnStart:
async def callback(changes):
callback_called.append(changes)
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths=str(temp_dir),
@@ -474,9 +492,7 @@ class TestRebuildIndexOnStart:
async def callback(changes):
callback_called.append(changes)
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths=str(temp_nested_dir),
@@ -510,9 +526,7 @@ class TestRebuildIndexOnStart:
async def callback(changes):
callback_called.append(changes)
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths=str(temp_nested_dir),
@@ -545,9 +559,7 @@ class TestRebuildIndexOnStart:
async def callback(changes):
callback_called.append(changes)
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
watcher = BaseFileWatcher(
watch_paths="/nonexistent/path",
@@ -698,9 +710,7 @@ class TestEdgeCases:
"""Test watching a single file instead of directory."""
file_path = temp_files["txt_0"]
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
callback_called = []
@@ -731,9 +741,7 @@ class TestEdgeCases:
empty_dir = temp_dir / "empty"
empty_dir.mkdir()
- mock_file_store = MagicMock()
- mock_file_store.list_files = AsyncMock(return_value=[])
- mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+ mock_file_store = make_mock_file_store()
callback_called = []
@@ -774,7 +782,7 @@ class TestEdgeCases:
unicode_dir.mkdir()
file_path = unicode_dir / "文件.txt"
- file_path.write_text("内容")
+ file_path.write_text("内容", encoding="utf-8")
watcher = BaseFileWatcher(
watch_paths=str(unicode_dir),
From 20b37414cbb0e0dde9a38d08f2aa59edc5af0515 Mon Sep 17 00:00:00 2001
From: "jinli.yl"
Date: Thu, 14 May 2026 14:40:07 +0800
Subject: [PATCH 08/16] fix(file-watcher): enable force polling for file
watcher
- Set force_polling=True in awatch to improve file detection reliability
- Bump version from 0.3.1.8 to 0.3.1.9
---
reme/__init__.py | 2 +-
reme/core/file_watcher/base_file_watcher.py | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/reme/__init__.py b/reme/__init__.py
index c1dbeb42..01261acd 100644
--- a/reme/__init__.py
+++ b/reme/__init__.py
@@ -6,7 +6,7 @@ from . import extension
from . import memory
from .reme import ReMe
-__version__ = "0.3.1.8"
+__version__ = "0.3.1.9"
__all__ = [
"config",
diff --git a/reme/core/file_watcher/base_file_watcher.py b/reme/core/file_watcher/base_file_watcher.py
index 4269117c..09b2f703 100644
--- a/reme/core/file_watcher/base_file_watcher.py
+++ b/reme/core/file_watcher/base_file_watcher.py
@@ -185,6 +185,7 @@ class BaseFileWatcher:
logger.info(f"Starting watch on valid paths: {valid_paths}")
async for changes in awatch(
*valid_paths,
+ force_polling=True,
watch_filter=self.watch_filter,
recursive=self.recursive,
debounce=self.debounce,
From e411eeb4c0472df3b4b525e972d7d7748e336578 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Sun, 17 May 2026 14:14:43 +0800
Subject: [PATCH 09/16] dev/reme4 init merge (#236)
---
.github/workflows/unittest.yml | 43 +
docs4/reme_design.md | 131 ++
docs4/todo.md | 7 +
pyproject.toml | 26 +-
reme4/__init__.py | 26 +
reme4/application.py | 174 ++
reme4/components/__init__.py | 43 +
reme4/components/application_context.py | 28 +
reme4/components/as_llm/__init__.py | 53 +
reme4/components/as_llm_formatter/__init__.py | 44 +
.../reme_openai_chat_formatter.py | 141 ++
reme4/components/as_token_counter/__init__.py | 35 +
.../estimate_token_counter.py | 21 +
reme4/components/base_component.py | 185 +++
reme4/components/client/__init__.py | 7 +
reme4/components/client/base_client.py | 41 +
reme4/components/client/http_client.py | 143 ++
reme4/components/client/mcp_client.py | 125 ++
reme4/components/component_registry.py | 77 +
reme4/components/embedding/__init__.py | 6 +
.../embedding/base_embedding_model.py | 214 +++
.../embedding/openai_embedding_model.py | 52 +
reme4/components/file_graph/__init__.py | 7 +
.../components/file_graph/base_file_graph.py | 53 +
.../components/file_graph/local_file_graph.py | 138 ++
reme4/components/file_graph/nx_file_graph.py | 122 ++
reme4/components/file_parser/__init__.py | 7 +
.../file_parser/bare_file_parser.py | 22 +
.../file_parser/base_file_parser.py | 30 +
.../file_parser/default_file_parser.py | 123 ++
reme4/components/file_store/__init__.py | 14 +
.../components/file_store/base_file_store.py | 100 ++
.../components/file_store/local_file_store.py | 177 +++
reme4/components/file_watcher/__init__.py | 9 +
.../file_watcher/base_file_watcher.py | 118 ++
.../file_watcher/lite_file_watcher.py | 129 ++
reme4/components/job/__init__.py | 6 +
reme4/components/job/base_job.py | 53 +
reme4/components/job/stream_job.py | 21 +
reme4/components/keyword_index/__init__.py | 6 +
.../keyword_index/base_keyword_index.py | 70 +
reme4/components/keyword_index/bm25_index.py | 206 +++
reme4/components/prompt_handler.py | 125 ++
reme4/components/runtime_context.py | 87 +
reme4/components/service/__init__.py | 11 +
reme4/components/service/base_service.py | 48 +
reme4/components/service/http_service.py | 99 ++
reme4/components/service/mcp_service.py | 71 +
reme4/components/tokenizer/__init__.py | 11 +
reme4/components/tokenizer/base_tokenizer.py | 44 +
reme4/components/tokenizer/jieba_tokenizer.py | 27 +
reme4/components/tokenizer/regex_tokenizer.py | 31 +
reme4/components/tokenizer/stopwords | 1395 +++++++++++++++++
reme4/config/__init__.py | 8 +
reme4/config/config_parser.py | 219 +++
reme4/config/default.yaml | 169 ++
reme4/constants.py | 7 +
reme4/enumeration/__init__.py | 9 +
reme4/enumeration/chunk_enum.py | 21 +
reme4/enumeration/component_enum.py | 37 +
reme4/reme.py | 43 +
reme4/schema/__init__.py | 25 +
reme4/schema/application_config.py | 45 +
reme4/schema/emb_node.py | 34 +
reme4/schema/file_chunk.py | 26 +
reme4/schema/file_front_matter.py | 24 +
reme4/schema/file_link.py | 18 +
reme4/schema/file_node.py | 16 +
reme4/schema/request.py | 11 +
reme4/schema/response.py | 15 +
reme4/schema/stream_chunk.py | 14 +
reme4/steps/__init__.py | 9 +
reme4/steps/base_step.py | 140 ++
reme4/steps/common/__init__.py | 21 +
reme4/steps/common/demo.py | 53 +
reme4/steps/common/health_check.py | 164 ++
reme4/steps/common/help.py | 42 +
reme4/steps/common/reindex.py | 24 +
reme4/steps/common/search.py | 227 +++
reme4/steps/common/stream_demo.py | 42 +
reme4/steps/common/version.py | 19 +
reme4/utils/__init__.py | 31 +
reme4/utils/common_utils.py | 249 +++
reme4/utils/env_utils.py | 36 +
reme4/utils/logger_utils.py | 108 ++
reme4/utils/logo_utils.py | 87 +
reme4/utils/service_utils.py | 96 ++
reme4/utils/similarity_utils.py | 35 +
tests4/unittest/test_bm25_index_perf.py | 337 ++++
tests4/unittest/test_bm25_lite.py | 586 +++++++
tests4/unittest/test_common_steps.py | 290 ++++
tests4/unittest/test_default_file_parser.py | 387 +++++
tests4/unittest/test_file_graph.py | 333 ++++
tests4/unittest/test_file_store.py | 330 ++++
tests4/unittest/test_file_watcher.py | 349 +++++
tests4/unittest/test_tokenizer.py | 169 ++
96 files changed, 9880 insertions(+), 7 deletions(-)
create mode 100644 .github/workflows/unittest.yml
create mode 100644 docs4/reme_design.md
create mode 100644 docs4/todo.md
create mode 100644 reme4/__init__.py
create mode 100644 reme4/application.py
create mode 100644 reme4/components/__init__.py
create mode 100644 reme4/components/application_context.py
create mode 100644 reme4/components/as_llm/__init__.py
create mode 100644 reme4/components/as_llm_formatter/__init__.py
create mode 100644 reme4/components/as_llm_formatter/reme_openai_chat_formatter.py
create mode 100644 reme4/components/as_token_counter/__init__.py
create mode 100644 reme4/components/as_token_counter/estimate_token_counter.py
create mode 100644 reme4/components/base_component.py
create mode 100644 reme4/components/client/__init__.py
create mode 100644 reme4/components/client/base_client.py
create mode 100644 reme4/components/client/http_client.py
create mode 100644 reme4/components/client/mcp_client.py
create mode 100644 reme4/components/component_registry.py
create mode 100644 reme4/components/embedding/__init__.py
create mode 100644 reme4/components/embedding/base_embedding_model.py
create mode 100644 reme4/components/embedding/openai_embedding_model.py
create mode 100644 reme4/components/file_graph/__init__.py
create mode 100644 reme4/components/file_graph/base_file_graph.py
create mode 100644 reme4/components/file_graph/local_file_graph.py
create mode 100644 reme4/components/file_graph/nx_file_graph.py
create mode 100644 reme4/components/file_parser/__init__.py
create mode 100644 reme4/components/file_parser/bare_file_parser.py
create mode 100644 reme4/components/file_parser/base_file_parser.py
create mode 100644 reme4/components/file_parser/default_file_parser.py
create mode 100644 reme4/components/file_store/__init__.py
create mode 100644 reme4/components/file_store/base_file_store.py
create mode 100644 reme4/components/file_store/local_file_store.py
create mode 100644 reme4/components/file_watcher/__init__.py
create mode 100644 reme4/components/file_watcher/base_file_watcher.py
create mode 100644 reme4/components/file_watcher/lite_file_watcher.py
create mode 100644 reme4/components/job/__init__.py
create mode 100644 reme4/components/job/base_job.py
create mode 100644 reme4/components/job/stream_job.py
create mode 100644 reme4/components/keyword_index/__init__.py
create mode 100644 reme4/components/keyword_index/base_keyword_index.py
create mode 100644 reme4/components/keyword_index/bm25_index.py
create mode 100644 reme4/components/prompt_handler.py
create mode 100644 reme4/components/runtime_context.py
create mode 100644 reme4/components/service/__init__.py
create mode 100644 reme4/components/service/base_service.py
create mode 100644 reme4/components/service/http_service.py
create mode 100644 reme4/components/service/mcp_service.py
create mode 100644 reme4/components/tokenizer/__init__.py
create mode 100644 reme4/components/tokenizer/base_tokenizer.py
create mode 100644 reme4/components/tokenizer/jieba_tokenizer.py
create mode 100644 reme4/components/tokenizer/regex_tokenizer.py
create mode 100644 reme4/components/tokenizer/stopwords
create mode 100644 reme4/config/__init__.py
create mode 100644 reme4/config/config_parser.py
create mode 100644 reme4/config/default.yaml
create mode 100644 reme4/constants.py
create mode 100644 reme4/enumeration/__init__.py
create mode 100644 reme4/enumeration/chunk_enum.py
create mode 100644 reme4/enumeration/component_enum.py
create mode 100644 reme4/reme.py
create mode 100644 reme4/schema/__init__.py
create mode 100644 reme4/schema/application_config.py
create mode 100644 reme4/schema/emb_node.py
create mode 100644 reme4/schema/file_chunk.py
create mode 100644 reme4/schema/file_front_matter.py
create mode 100644 reme4/schema/file_link.py
create mode 100644 reme4/schema/file_node.py
create mode 100644 reme4/schema/request.py
create mode 100644 reme4/schema/response.py
create mode 100644 reme4/schema/stream_chunk.py
create mode 100644 reme4/steps/__init__.py
create mode 100644 reme4/steps/base_step.py
create mode 100644 reme4/steps/common/__init__.py
create mode 100644 reme4/steps/common/demo.py
create mode 100644 reme4/steps/common/health_check.py
create mode 100644 reme4/steps/common/help.py
create mode 100644 reme4/steps/common/reindex.py
create mode 100644 reme4/steps/common/search.py
create mode 100644 reme4/steps/common/stream_demo.py
create mode 100644 reme4/steps/common/version.py
create mode 100644 reme4/utils/__init__.py
create mode 100644 reme4/utils/common_utils.py
create mode 100644 reme4/utils/env_utils.py
create mode 100644 reme4/utils/logger_utils.py
create mode 100644 reme4/utils/logo_utils.py
create mode 100644 reme4/utils/service_utils.py
create mode 100644 reme4/utils/similarity_utils.py
create mode 100644 tests4/unittest/test_bm25_index_perf.py
create mode 100644 tests4/unittest/test_bm25_lite.py
create mode 100644 tests4/unittest/test_common_steps.py
create mode 100644 tests4/unittest/test_default_file_parser.py
create mode 100644 tests4/unittest/test_file_graph.py
create mode 100644 tests4/unittest/test_file_store.py
create mode 100644 tests4/unittest/test_file_watcher.py
create mode 100644 tests4/unittest/test_tokenizer.py
diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml
new file mode 100644
index 00000000..263bd23a
--- /dev/null
+++ b/.github/workflows/unittest.yml
@@ -0,0 +1,43 @@
+name: Tests ReMe
+
+on:
+ push:
+ branches: [main, master, dev, develop]
+ pull_request:
+ branches: [main, master, dev, develop]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ unit-tests:
+ name: Unit Tests - py${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: 'pip'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ pip install -e ".[dev,core]"
+
+ - name: Run tests4 unit tests
+ run: |
+ pytest tests4/unittest \
+ -v \
+ --tb=long \
+ -s \
+ --log-cli-level=WARNING
diff --git a/docs4/reme_design.md b/docs4/reme_design.md
new file mode 100644
index 00000000..66acb982
--- /dev/null
+++ b/docs4/reme_design.md
@@ -0,0 +1,131 @@
+# 快速测试
+
+```bash
+# 终端 A:启动服务
+reme4 start
+
+# 终端 B:调用 version 验证服务可用
+reme4 version
+# 预期输出:✅ ReMe v{__version__}
+```
+
+# 基础Job
+
+@jinli
+说明:📥 输入参数 | 📤 输出 | ⭐ 必填 | 🎚️ 默认值 | 🛠️ 内部行为
+
+| 分类 | 能力 (register name) | 参数 & 行为 |
+|-----------|--------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 🌐 通用 | 🆘 `help` (`help_step`) | 📥 无 | 📤 `answer` 一行一个 job:`🛠️ \`{name}\` — {description} 📥 {params}`,参数渲染为 `name:type*`(必填) / `name:type={default}` / `name:type` | 📊 `metadata.job_count` | 🛠️ 自动跳过名为 `help` 的 job |
+| 🌐 通用 | 🩺 `health_check` (`health_check_step`) | 📥 无 | 📤 `answer = "✅/❌ ReMe v{version} - healthy/unhealthy"` | 📊 `metadata.health = {version, healthy, components}` | 🧩 覆盖组件:`embedding_model`(🟢 is_started/is_healthy/model_name/dimensions/cache_size/memory) · `file_graph`(🕸️ n_nodes/n_edges/n_virtual\|n_pending/memory) · `file_store`(📦 n_chunks/n_chunks_with_embedding/memory) · `file_watcher`(👀 background_running/watch_paths) · `keyword_index`(🔤 n_docs/vocab_size/memory) | 🛠️ deep sizeof(含 numpy.nbytes),未启动 / 后台未跑 / embedding 不健康 → ❌ |
+| 🌐 通用 | 🏷️ `version` (`version_step`) | 📥 无 | 📤 `answer = reme4.__version__` | 📊 `metadata.version` |
+| 🌐 通用 | 🔄 `reindex` (`reindex_step`) | 📥 无 | 📤 `answer = "🔄 Reindexed {added} file(s)"` | 📊 `metadata.counts = {added, ...}` | 🛠️ 流程:`file_watcher.close()` → `file_store.clear()` → `file_watcher.update_store()` → `file_watcher.start()`(finally 保证重启) |
+| 🔎 search | 🔍 `search` (`search_step`) | 📥 `query:str` ⭐ | 🎚️ `limit:int=5`(>0) | 🎚️ `min_score:float=0.0` | ⚖️ `vector_weight:float=0.7` ∈[0,1](keyword 权 = 1-vw)| 🔀 `candidate_multiplier:float=3.0`(candidates = min(200, limit×mult))| 🔗 `expand_links:bool=True` | 🔢 `max_links_per_direction:int=10` | 🎚️ `search_filter:dict={}` | 📤 `answer` 每命中一行 `path:start-end [score=… vector=… keyword=…] text` + 缩进的 `→ outlinks (n)` / `← inlinks (n)` + `via predicate=… anchor=#…` | 📊 `metadata.results` / `metadata.link_expansion` / `metadata.counts={vector,keyword,returned,hybrid}` | 🛠️ 并行 `vector_search` + `keyword_search` → RRF 融合(K=60,按 chunk.id 合并)→ `min_score` 过滤 → `limit` 截断 → 邻居 meta 注入 |
+| 🧪 demo | 🪄 `demo_echo` (`demo_echo_step1` + `step2`) | 📥 `query:str=""` | 🎚️ `min_score:float=0.5` | 🛠️ step1:`processed_query = query.strip().lower()`,`adjusted_min_score = min_score * 0.9`,写回 context | 📤 step2:`answer = "echo: {processed_query} (min_score={adjusted_min_score})"` | 📊 `metadata = {step, query, min_score, processed_query, adjusted_min_score}` |
+| 🌊 demo | 🌊 `stream_demo` (`stream_demo_step1` + `step2`) | 📥 `query:str=""` | 🎚️ `repeat:int=10` | 🎚️ `interval:float=0.1`(秒/字符)| 🛠️ step1:`stream_text = query * repeat` 写回 context | 📤 step2:按字符 `add_stream_string(ch, ChunkEnum.CONTENT)` 流式输出,`asyncio.sleep(interval)` 节流 |
+
+@sen
+| tags | stat | 返回特定tag信息 |
+| tags | list | 返回所有tag列表 |
+| crud | upload/download | 其他文件 |
+| file | stat | path |
+| file | list | path |
+| property | property:read | |
+| property | property:update | path="My Note" status=done xx=xxx |
+| property | property:delete | keys="[xxxx, xxxx]" |
+| graph | traverse | path="My Note" directtion=forward/backward depth=1 predicat=xxx |
+
+@wangce
+| crud | create | path="New Note" content="# Hello" title="xxx" tags="[]" status="" |
+| crud | read | path="Templates/Recipe.md" |
+| crud | edit | path="Templates/Recipe.md" old="xxx" new="xxx" |
+| crud | append | path="My Note" content="New line" |
+| crud | prepend | path="My Note" content="New line" |
+| crud | delete | path="My Note
+| daily:crud | daily:xxx | 与 crud 参数保持一致 |
+
+# 日记类型
+
+| 类型 | 路径 | 说明 |
+|-----------|-----------------------------------------------|-----------------------------|
+| daily | {daily}/xxxx-mm-dd.md + xxxx-mm-dd/{event}.md | 按日期归档的原始信息记录 |
+| topic | topic/{topic:-personal(agent)}/{xxxx}.md | 按主题聚类的二次加工内容 |
+| proactive | todo | 基于 daily / topic 思考后主动推送的消息 |
+
+# 生成Job
+
+| 任务 | 输入 | 输出 | 触发时机 | 说明 |
+|-------------------------|---------------|-----------------------------------------------|-----------------------------|------------------------------------------------------|
+| 日记summary @sen @wangce | msg | {daily}/xxxx-mm-dd.md + xxxx-mm-dd/{event}.md | freq (every_n_turn、compact) | 把 msg 的信息写入 daily 目录 |
+| 主题dream + 生成链接 @sen | daily/xxx | knowledge/xxx | /dream | 把 daily 目录的内容按主题聚类合并到 topic 目录, 主动在文档中建立 [[link]] 关联 |
+| 主动proactive @wangce | daily / topic | proactive_query | pre_query | 思考 daily / topic 信息,主动决定推送给用户的消息 |
+
+2. file_parser
+ a. 抽象基类 parse: @jinli
+ ⅰ. 输入是path:相对路径
+ ⅱ. 输出是FileMetadata & list[FileChunks] & list[FileEdge]
+ b. default parser 兼容老方案 @jinli
+ ⅰ. 带overlap的chunking策略 ,不输出FileEdge
+ c. markdown parser @sen
+ ⅰ. 根据markdown ast做chunk,不需要overlap
+ ⅱ. 增加一个索引的chunk chunk_type @锦鲤 file_chunk_type content/index
+ ⅲ. 增加link的正则解析:predicate:: [[path#anchor]]
+3. file_store @sen
+ a. 抽象存储:
+ ⅰ. filenode = file + path + st_mtime + metadata + list[FileEdge]
+ ⅱ. graph=dict[str, filenode] 内存+json
+ ⅲ. list[FileChunk] 存db
+ b. 抽象基类
+ ⅰ. graph:fellow dict的操作 update/get/set
+ ⅱ. chunks dict[str, list[chunk]]
+ 1. delete_chunks_by_path
+ 2. update_chunks_by_path
+ 3. list_chunks_by_path
+ 4. vector_search/keyword_search
+ ⅲ. 手写一个bm25检索
+ ⅳ. 【核心】检索机制 vector bm25 graph 如何进行融合
+4. file_watcher @jinli
+ a. 抽象基类
+ ⅰ. on_start:
+ 1. file_store 的start 在前,加载graph,file_watcher在后,递归扫描目录
+ a. 通过ms_time对比graph,on_change 进行改动
+ ⅱ. on_change:
+ 1. 更新/增加:
+ a. delete_chunks_by_path 更新数据库
+ b. upate_chunks_by_path 更新数据库
+ c. 更新graph
+ 2. 删除
+ a. delete_chunks_by_path 更新数据库
+
+MemorySchema
+
+1. markdown文件结构 @sen
+ a. formatter:
+ ⅰ. title
+ ⅱ. desc
+ ⅲ. tags
+ ⅳ.
+2. memory文件结构目录
+ a. MEMORY.md
+ b. msg/files -> daily/YYYYMMDD/YYYYMMDD.md + xxxx.md
+ ⅰ. YYYYMMDD.md
+ 1. xxx -> xxxx.md
+ 2. xxx -> xxxd.md
+ ⅱ.
+ c. daily -> topic/topic_l1/topic_l1.md + xxx.md + topic_l2
+ d. proactive
+
+steps:
+
+1. 治理(算法+LLM):
+ a. 节点关联P0:现有的链接做补充,挖掘新的LLM的link
+ ⅰ. /Users/yuli/workspace/ReMe/reme2/component/edge_extractor/llm_edge_extractor.py
+ ⅱ. 移动到steps
+ b. 节点整合/节点拆分/节点归档
+ c. 健康度检查
+2. retrieve 调用store的检索
+3. 原子steps:reme edit
+4. 组合steps:总结:
+ a. - freq (every_n_turn、compact) -> daily_summarizer
+ b. topic (/dream ) -> topic_summarizer(daily_xx -> topic_xx)
+ c. proactive -> proactive_summarizer(personal_xxx -> proactive_query - pre_query
diff --git a/docs4/todo.md b/docs4/todo.md
new file mode 100644
index 00000000..938edcc1
--- /dev/null
+++ b/docs4/todo.md
@@ -0,0 +1,7 @@
+1. 完善mcp_servers config
+2. 完善mcp/http的服务测试
+3. [PosixPath('.reme')]
+4. error
+5. meta信息存在一个地方
+6. 测试一个完整的Service client的框架,测试各种命令
+7. config 默认改成default
diff --git a/pyproject.toml b/pyproject.toml
index 11177ffd..c9f644af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,9 +33,7 @@ classifiers = [
keywords = ["llm", "memory", "experience", "memoryscope", "ai", "mcp", "http", "reme", "personal"]
dependencies = [
- "sqlite-vec>=0.1.6",
- "prompt_toolkit>=3.0.52",
- "rich>=14.2.0",
+ "aiofiles>=24.1.0",
"asyncpg>=0.31.0",
"chromadb>=1.3.5",
"dashscope>=1.25.1",
@@ -43,23 +41,28 @@ dependencies = [
"fastapi>=0.121.3",
"fastmcp>=2.14.1",
"httpx>=0.28.1",
+ "jieba>=0.42.1",
"loguru>=0.7.3",
"mcp>=1.25.0",
+ "networkx>=3.4",
"numpy>=2.2.6",
"openai>=2.8.1",
"pandas>=2.3.3",
+ "prompt_toolkit>=3.0.52",
"pydantic>=2.12.4",
"pyobvector>=0.1.20",
+ "pyyaml>=6.0.3",
+ "qdrant-client>=1.16.0",
+ "rich>=14.2.0",
+ "sqlite-vec>=0.1.6",
# pyobvector imports Expression from sqlglot; removed from sqlglot 30+ top-level API
"sqlglot>=25,<30",
- "qdrant-client>=1.16.0",
"tavily-python>=0.7.13",
"tiktoken>=0.12.0",
"tqdm>=4.67.1",
"transformers>=4.57.3",
"uvicorn>=0.40.0",
"watchfiles>=1.1.1",
- "pyyaml>=6.0.3",
]
[project.optional-dependencies]
@@ -75,6 +78,8 @@ dev = [
"furo",
"sphinxcontrib-mermaid",
"pre-commit",
+ "pytest>=8.0",
+ "pytest-asyncio>=0.23",
]
full = [
@@ -85,14 +90,14 @@ litellm = [
"litellm==1.80.0",
]
-light = [
+core = [
"agentscope==1.0.18",
"flowllm[reme]>=0.2.0.10",
]
[tool.setuptools.packages.find]
where = ["."]
-include = ["reme_ai*", "reme*"]
+include = ["reme_ai*", "reme*", "reme4*"]
exclude = ["test*", "cookbook*", "doc*", "library*", "dist*"]
[tool.setuptools.package-data]
@@ -108,6 +113,12 @@ reme = [
"**/*.json",
]
+reme4 = [
+ "**/*.yaml",
+ "**/*.py",
+ "**/*.json",
+]
+
[tool.setuptools.dynamic]
version = { attr = "reme.__version__" }
@@ -120,6 +131,7 @@ Repository = "https://github.com/agentscope-ai/ReMe"
reme = "reme_ai.main:main"
reme2 = "reme.reme:main"
remecli = "reme.reme_cli:main"
+reme4 = "reme4.reme:main"
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
diff --git a/reme4/__init__.py b/reme4/__init__.py
new file mode 100644
index 00000000..e4db3de0
--- /dev/null
+++ b/reme4/__init__.py
@@ -0,0 +1,26 @@
+"""ReMe CLI package."""
+
+__version__ = "0.4.0.0"
+
+from . import config
+from . import constants
+from . import enumeration
+from . import schema
+from . import steps
+from . import utils
+from .application import Application
+from .components import BaseComponent
+from .reme import ReMe
+
+__all__ = [
+ "Application",
+ "BaseComponent",
+ "ReMe",
+ # submodules
+ "config",
+ "constants",
+ "enumeration",
+ "schema",
+ "steps",
+ "utils",
+]
diff --git a/reme4/application.py b/reme4/application.py
new file mode 100644
index 00000000..a06dadbd
--- /dev/null
+++ b/reme4/application.py
@@ -0,0 +1,174 @@
+"""Main application entry point."""
+
+import asyncio
+import heapq
+from pathlib import Path
+from typing import AsyncGenerator
+
+from .components import BaseComponent, ApplicationContext
+from .enumeration import ComponentEnum
+from .schema import Response, StreamChunk
+from .utils import execute_stream_task, print_logo, get_logger
+
+
+class Application(BaseComponent):
+ """Main application: initializes components, resolves dependencies, runs jobs."""
+
+ def __init__(self, **kwargs) -> None:
+ self.context = ApplicationContext(**kwargs)
+
+ working_path = Path(self.config.working_dir).absolute()
+ working_path.mkdir(parents=True, exist_ok=True)
+ (working_path / self.config.metadata_dir).mkdir(parents=True, exist_ok=True)
+ (working_path / self.config.daily_dir).mkdir(parents=True, exist_ok=True)
+ (working_path / self.config.knowledge_dir).mkdir(parents=True, exist_ok=True)
+
+ if self.config.enable_logo:
+ print_logo(self.config)
+
+ logger = get_logger(
+ log_to_console=self.config.log_to_console,
+ log_to_file=self.config.log_to_file,
+ force_init=True,
+ )
+ logger.info(f"Initializing {self.config.app_name} Application")
+ super().__init__()
+
+ from .components import R
+
+ # Service
+ service_config = self.config.service
+ if not service_config.backend:
+ raise ValueError("Service configuration is missing the required 'backend' field")
+ service_cls = R.get(ComponentEnum.SERVICE, service_config.backend)
+ if not service_cls:
+ raise ValueError(f"Unregistered service backend '{service_config.backend}'")
+ params = service_config.model_dump()
+ params["app_context"] = self.context
+ self.context.service = service_cls(**params)
+
+ # Components
+ for component_type, component_configs in self.config.components.items():
+ self.context.components[component_type] = {}
+ for name, config in component_configs.items():
+ if not config.backend:
+ raise ValueError(f"Component '{name}' is missing the required 'backend' field")
+ backend_cls = R.get(component_type, config.backend)
+ if not backend_cls:
+ raise ValueError(f"Unregistered backend '{config.backend}' for component '{name}'")
+ params = config.model_dump()
+ params.setdefault("name", name)
+ params["app_context"] = self.context
+ self.context.components[component_type][name] = backend_cls(**params)
+
+ # Jobs
+ for job_config in self.config.jobs:
+ if not job_config.backend:
+ raise ValueError(f"Job '{job_config.name}' is missing the required 'backend' field")
+ job_cls = R.get(ComponentEnum.JOB, job_config.backend)
+ if not job_cls:
+ raise ValueError(f"Unregistered backend '{job_config.backend}' for job '{job_config.name}'")
+ params = job_config.model_dump()
+ params["app_context"] = self.context
+ self.context.jobs[job_config.name] = job_cls(**params)
+
+ @property
+ def config(self):
+ """Application configuration."""
+ return self.context.app_config
+
+ def _topological_order(self) -> list[BaseComponent]:
+ """Kahn's algorithm. Raises on missing required dep or cycle."""
+ nodes: dict[tuple[ComponentEnum, str], BaseComponent] = {
+ (ctype, name): comp for ctype, group in self.context.components.items() for name, comp in group.items()
+ }
+
+ in_degree: dict[tuple[ComponentEnum, str], int] = dict.fromkeys(nodes, 0)
+ dependents: dict[tuple[ComponentEnum, str], list[tuple[ComponentEnum, str]]] = {k: [] for k in nodes}
+ for key, comp in nodes.items():
+ for dep in comp.dependencies:
+ dep_key = (dep.ctype, dep.name)
+ if dep_key in nodes:
+ dependents[dep_key].append(key)
+ in_degree[key] += 1
+ elif not dep.optional:
+ raise ValueError(
+ f"Component {key[0].value}:{key[1]} depends on {dep.ctype.value}:{dep.name}, not registered",
+ )
+
+ ready = [k for k, d in in_degree.items() if d == 0]
+ heapq.heapify(ready)
+ ordered: list[BaseComponent] = []
+ while ready:
+ key = heapq.heappop(ready)
+ ordered.append(nodes[key])
+ for downstream in dependents[key]:
+ in_degree[downstream] -= 1
+ if in_degree[downstream] == 0:
+ heapq.heappush(ready, downstream)
+
+ if len(ordered) != len(nodes):
+ unresolved = [f"{k[0].value}:{k[1]}" for k, d in in_degree.items() if d > 0]
+ raise ValueError(f"Circular dependency detected among: {unresolved}")
+ return ordered
+
+ async def _start(self) -> None:
+ """Start components in topological order, then jobs."""
+ start_order = self._topological_order()
+ order_str = " -> ".join(f"{c.component_type.value}:{c.name}" for c in start_order)
+ self.logger.info(f"Component start order: {order_str}")
+
+ for component in start_order:
+ try:
+ await component.start()
+ except Exception as e:
+ self.logger.exception(f"Failed to start {component.component_type.value}:{component.name}: {e}")
+
+ for name, job in self.context.jobs.items():
+ try:
+ await job.start()
+ except Exception as e:
+ self.logger.exception(f"Failed to start job '{name}': {e}")
+
+ async def _close(self) -> None:
+ """Close all jobs, then components in reverse."""
+ for name, job in self.context.jobs.items():
+ try:
+ await job.close()
+ except Exception as e:
+ self.logger.exception(f"Failed to close job '{name}': {e}")
+
+ for components in self.context.components.values():
+ for component in components.values():
+ try:
+ await component.close()
+ except Exception as e:
+ self.logger.exception(f"Failed to close {component.component_type.value}:{component.name}: {e}")
+
+ async def run_job(self, name: str, /, **kwargs) -> Response:
+ """Execute a registered job by name."""
+ if name not in self.context.jobs:
+ raise KeyError(f"Job '{name}' not found")
+ return await self.context.jobs[name](**kwargs)
+
+ async def run_stream_job(self, name: str, /, **kwargs) -> AsyncGenerator[StreamChunk, None]:
+ """Execute a streaming job and yield chunks."""
+ if name not in self.context.jobs:
+ raise KeyError(f"Job '{name}' not found")
+ job = self.context.jobs[name]
+ stream_queue = asyncio.Queue()
+ task = asyncio.create_task(job(stream_queue=stream_queue, **kwargs))
+ async for chunk in execute_stream_task(
+ stream_queue=stream_queue,
+ task=task,
+ task_name=name,
+ output_format="chunk",
+ ):
+ assert isinstance(chunk, StreamChunk)
+ yield chunk
+
+ def run_app(self):
+ """Start the service and serve the application."""
+ if self.context.service is None:
+ raise RuntimeError("Service not configured")
+ self.context.service.run_app(app=self)
diff --git a/reme4/components/__init__.py b/reme4/components/__init__.py
new file mode 100644
index 00000000..148eeeaa
--- /dev/null
+++ b/reme4/components/__init__.py
@@ -0,0 +1,43 @@
+"""Components"""
+
+from . import as_llm
+from . import as_llm_formatter
+from . import as_token_counter
+from . import client
+from . import embedding
+from . import file_graph
+from . import file_parser
+from . import file_store
+from . import file_watcher
+from . import job
+from . import keyword_index
+from . import service
+from . import tokenizer
+from .application_context import ApplicationContext
+from .base_component import BaseComponent
+from .component_registry import ComponentRegistry, R
+from .prompt_handler import PromptHandler
+from .runtime_context import RuntimeContext
+
+__all__ = [
+ "ApplicationContext",
+ "BaseComponent",
+ "ComponentRegistry",
+ "R",
+ "PromptHandler",
+ "RuntimeContext",
+ # base components
+ "as_llm",
+ "as_llm_formatter",
+ "as_token_counter",
+ "client",
+ "embedding",
+ "file_graph",
+ "file_parser",
+ "file_store",
+ "file_watcher",
+ "job",
+ "keyword_index",
+ "service",
+ "tokenizer",
+]
diff --git a/reme4/components/application_context.py b/reme4/components/application_context.py
new file mode 100644
index 00000000..90a7f194
--- /dev/null
+++ b/reme4/components/application_context.py
@@ -0,0 +1,28 @@
+"""Application context: shared state container for components, jobs, and service."""
+
+from ..enumeration import ComponentEnum
+from ..schema import ApplicationConfig
+
+
+class ApplicationContext:
+ """Holds the parsed config and instantiated components, jobs, and service.
+
+ Acts as a passive state container. The actual wiring (resolving backends from
+ the registry and instantiating each component) is performed by Application.
+ """
+
+ def __init__(self, **kwargs):
+ # Parse and validate raw config kwargs into a typed ApplicationConfig.
+ self.app_config: ApplicationConfig = ApplicationConfig(**kwargs)
+
+ # Local imports to avoid circular dependencies during module init.
+ from .base_component import BaseComponent
+ from .job import BaseJob
+ from .service import BaseService
+
+ # Service endpoint (e.g. HTTP/MCP). Populated by Application.__init__.
+ self.service: BaseService | None = None
+ # Components keyed by type then by user-defined name.
+ self.components: dict[ComponentEnum, dict[str, BaseComponent]] = {}
+ # Jobs keyed by user-defined name.
+ self.jobs: dict[str, BaseJob] = {}
diff --git a/reme4/components/as_llm/__init__.py b/reme4/components/as_llm/__init__.py
new file mode 100644
index 00000000..82ae553f
--- /dev/null
+++ b/reme4/components/as_llm/__init__.py
@@ -0,0 +1,53 @@
+"""AgentScope LLM model wrappers."""
+
+from agentscope.model import AnthropicChatModel, ChatModelBase, OpenAIChatModel
+
+from ..base_component import BaseComponent
+from ..component_registry import R
+from ...enumeration import ComponentEnum
+
+
+class BaseAsLLM(BaseComponent):
+ """Base wrapper for AgentScope chat models. Builds ``self.model`` in ``_start``."""
+
+ component_type = ComponentEnum.AS_LLM
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.model: ChatModelBase | None = None
+
+ async def _close(self) -> None:
+ self.model = None
+
+
+@R.register("openai")
+class OpenAIAsLLM(BaseAsLLM):
+ """OpenAI chat model wrapper."""
+
+ async def _start(self) -> None:
+ self.model = OpenAIChatModel(**self.kwargs)
+
+ async def _close(self) -> None:
+ if self.model is not None:
+ assert isinstance(self.model, OpenAIChatModel)
+ await self.model.client.close()
+
+
+@R.register("anthropic")
+class AnthropicAsLLM(BaseAsLLM):
+ """Anthropic chat model wrapper."""
+
+ async def _start(self) -> None:
+ self.model = AnthropicChatModel(**self.kwargs)
+
+ async def _close(self) -> None:
+ if self.model is not None:
+ assert isinstance(self.model, AnthropicChatModel)
+ await self.model.client.close()
+
+
+__all__ = [
+ "BaseAsLLM",
+ "OpenAIAsLLM",
+ "AnthropicAsLLM",
+]
diff --git a/reme4/components/as_llm_formatter/__init__.py b/reme4/components/as_llm_formatter/__init__.py
new file mode 100644
index 00000000..81e4fe37
--- /dev/null
+++ b/reme4/components/as_llm_formatter/__init__.py
@@ -0,0 +1,44 @@
+"""AgentScope LLM formatter wrappers."""
+
+from agentscope.formatter import AnthropicChatFormatter, FormatterBase
+
+from .reme_openai_chat_formatter import ReMeOpenAIChatFormatter
+from ..base_component import BaseComponent
+from ..component_registry import R
+from ...enumeration import ComponentEnum
+
+
+class BaseAsLLMFormatter(BaseComponent):
+ """Base wrapper for AgentScope formatters. Builds ``self.formatter`` in ``_start``."""
+
+ component_type = ComponentEnum.AS_LLM_FORMATTER
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.formatter: FormatterBase | None = None
+
+ async def _close(self) -> None:
+ self.formatter = None
+
+
+@R.register("openai")
+class AsOpenAIChatFormatter(BaseAsLLMFormatter):
+ """OpenAI chat formatter wrapper (uses ReMe extensions)."""
+
+ async def _start(self) -> None:
+ self.formatter = ReMeOpenAIChatFormatter(**self.kwargs)
+
+
+@R.register("anthropic")
+class AsAnthropicChatFormatter(BaseAsLLMFormatter):
+ """Anthropic chat formatter wrapper."""
+
+ async def _start(self) -> None:
+ self.formatter = AnthropicChatFormatter(**self.kwargs)
+
+
+__all__ = [
+ "BaseAsLLMFormatter",
+ "AsOpenAIChatFormatter",
+ "AsAnthropicChatFormatter",
+]
diff --git a/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py b/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py
new file mode 100644
index 00000000..a40f977d
--- /dev/null
+++ b/reme4/components/as_llm_formatter/reme_openai_chat_formatter.py
@@ -0,0 +1,141 @@
+"""OpenAI chat formatter with ReMe extensions: image promotion and reasoning_content."""
+
+import json
+from typing import Any
+
+from agentscope.formatter import OpenAIChatFormatter
+
+# noinspection PyProtectedMember
+from agentscope.formatter._openai_formatter import (
+ _format_openai_image_block,
+ _to_openai_audio_data,
+)
+from agentscope.message import Msg, TextBlock, ImageBlock, URLSource
+
+
+def _format_openai_video_block(video_block: dict) -> dict[str, Any]:
+ """Convert a video block to OpenAI ``video_url`` content."""
+ source = video_block["source"]
+ if source["type"] == "url":
+ url = source["url"]
+ elif source["type"] == "base64":
+ url = f"data:{source['media_type']};base64,{source['data']}"
+ else:
+ raise ValueError(f"Unsupported video source type: {source['type']}")
+ return {"type": "video_url", "video_url": {"url": url}}
+
+
+class ReMeOpenAIChatFormatter(OpenAIChatFormatter):
+ """OpenAIChatFormatter + tool-result image promotion + reasoning_content passthrough."""
+
+ async def _format(self, msgs: list[Msg]) -> list[dict[str, Any]]:
+ """Format ``Msg`` list into OpenAI chat-completion message dicts."""
+ self.assert_list_of_msgs(msgs)
+
+ messages: list[dict] = []
+ i = 0
+ while i < len(msgs):
+ msg = msgs[i]
+ content_blocks = []
+ tool_calls = []
+ reasoning_content_blocks = []
+
+ for block in msg.get_content_blocks():
+ typ = block.get("type")
+
+ if typ == "text":
+ content_blocks.append({**block})
+
+ elif typ == "thinking":
+ reasoning_content_blocks.append({**block})
+
+ elif typ == "tool_use":
+ tool_calls.append(
+ {
+ "id": block.get("id"),
+ "type": "function",
+ "function": {
+ "name": block.get("name"),
+ "arguments": json.dumps(block.get("input", {}), ensure_ascii=False),
+ },
+ },
+ )
+
+ elif typ == "tool_result":
+ textual_output, multimodal_data = self.convert_tool_result_to_string(block["output"])
+ messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": block.get("id"),
+ "content": textual_output,
+ "name": block.get("name"),
+ },
+ )
+
+ # OpenAI tool messages can't carry images; promote to a follow-up user message.
+ promoted_blocks = []
+ for url, multimodal_block in multimodal_data:
+ if multimodal_block["type"] == "image" and self.promote_tool_result_images:
+ promoted_blocks.extend(
+ [
+ TextBlock(type="text", text=f"\n- The image from '{url}': "),
+ ImageBlock(type="image", source=URLSource(type="url", url=url)),
+ ],
+ )
+
+ if promoted_blocks:
+ promoted_blocks = [
+ TextBlock(
+ type="text",
+ text="The following are the image contents from the tool "
+ f"result of '{block['name']}':",
+ ),
+ *promoted_blocks,
+ TextBlock(type="text", text=""),
+ ]
+ msgs.insert(
+ i + 1,
+ Msg(name="user", content=promoted_blocks, role="user"),
+ )
+
+ elif typ == "image":
+ content_blocks.append(_format_openai_image_block(block))
+
+ elif typ == "audio":
+ # Skip assistant audio — not a valid input modality.
+ if msg.role == "assistant":
+ continue
+ content_blocks.append(
+ {
+ "type": "input_audio",
+ "input_audio": _to_openai_audio_data(block["source"]),
+ },
+ )
+
+ elif typ == "video":
+ # Skip assistant video — not a valid input modality.
+ if msg.role == "assistant":
+ continue
+ content_blocks.append(_format_openai_video_block(block))
+
+ msg_openai = {
+ "role": msg.role,
+ "name": msg.name,
+ "content": content_blocks or None,
+ }
+
+ if tool_calls:
+ msg_openai["tool_calls"] = tool_calls
+
+ # Merge thinking blocks into reasoning_content for compatible models.
+ if reasoning_content_blocks:
+ reasoning_msg = "\n".join(r.get("thinking", "") for r in reasoning_content_blocks)
+ if reasoning_msg:
+ msg_openai["reasoning_content"] = reasoning_msg
+
+ if msg_openai["content"] or msg_openai.get("tool_calls"):
+ messages.append(msg_openai)
+
+ i += 1
+
+ return messages
diff --git a/reme4/components/as_token_counter/__init__.py b/reme4/components/as_token_counter/__init__.py
new file mode 100644
index 00000000..36bef1bc
--- /dev/null
+++ b/reme4/components/as_token_counter/__init__.py
@@ -0,0 +1,35 @@
+"""AgentScope token counter wrappers."""
+
+from agentscope.token import TokenCounterBase
+
+from .estimate_token_counter import EstimatedTokenCounter
+from ..base_component import BaseComponent
+from ..component_registry import R
+from ...enumeration import ComponentEnum
+
+
+class BaseAsTokenCounter(BaseComponent):
+ """Base wrapper for AgentScope token counters. Builds ``self.token_counter`` in ``_start``."""
+
+ component_type = ComponentEnum.AS_TOKEN_COUNTER
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.token_counter: TokenCounterBase | None = None
+
+ async def _close(self) -> None:
+ self.token_counter = None
+
+
+@R.register("estimated")
+class EstimatedAsTokenCounter(BaseAsTokenCounter):
+ """Character-based estimated token counter — fast but approximate."""
+
+ async def _start(self) -> None:
+ self.token_counter = EstimatedTokenCounter(**self.kwargs)
+
+
+__all__ = [
+ "BaseAsTokenCounter",
+ "EstimatedAsTokenCounter",
+]
diff --git a/reme4/components/as_token_counter/estimate_token_counter.py b/reme4/components/as_token_counter/estimate_token_counter.py
new file mode 100644
index 00000000..b5566dec
--- /dev/null
+++ b/reme4/components/as_token_counter/estimate_token_counter.py
@@ -0,0 +1,21 @@
+"""Character-based token-count estimator."""
+
+from agentscope.token import TokenCounterBase
+
+
+class EstimatedTokenCounter(TokenCounterBase):
+ """Approximate token count as ``encoded_byte_len / divisor``.
+
+ Cheap proxy when exact counts aren't needed; use the model's real
+ tokenizer for accuracy.
+ """
+
+ def __init__(self, estimate_divisor: float = 4, encoding: str = "utf-8"):
+ if estimate_divisor <= 0:
+ raise ValueError("estimate_divisor must be positive")
+ self.estimate_divisor: float = estimate_divisor
+ self.encoding: str = encoding
+
+ async def count(self, text: str, **_kwargs) -> int:
+ """Estimated token count for ``text``."""
+ return int(len(text.encode(self.encoding)) / self.estimate_divisor + 0.5)
diff --git a/reme4/components/base_component.py b/reme4/components/base_component.py
new file mode 100644
index 00000000..4c614d52
--- /dev/null
+++ b/reme4/components/base_component.py
@@ -0,0 +1,185 @@
+"""Base class for components."""
+
+import asyncio
+from abc import ABC
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
+
+from ..enumeration import ComponentEnum
+from ..utils import get_logger
+
+if TYPE_CHECKING:
+ from .application_context import ApplicationContext
+
+T = TypeVar("T", bound="BaseComponent")
+
+
+class Dependency:
+ """Declared dependency: bind() return value, instance attribute placeholder, and topological-sort edge."""
+
+ __slots__ = ("ctype", "name", "default_factory", "optional")
+
+ def __init__(
+ self,
+ ctype: ComponentEnum,
+ name: str,
+ default_factory: Callable[[], Any] | None = None,
+ optional: bool = True,
+ ) -> None:
+ self.ctype = ctype
+ self.name = name
+ self.default_factory = default_factory
+ self.optional = optional
+
+ def __repr__(self) -> str:
+ suffix = "?" if self.optional else ""
+ return f""
+
+ def __getattr__(self, item: str) -> Any:
+ # Guard against using the dependency before start() resolves it.
+ raise RuntimeError(
+ f"Dependency {self.ctype.value}:{self.name} accessed before start() (attribute '{item}')",
+ )
+
+
+class BaseComponent(ABC):
+ """Async lifecycle base class with bind-based dependency injection."""
+
+ component_type = ComponentEnum.BASE
+
+ def __init__(
+ self,
+ name: str | None = None,
+ backend: str = "",
+ app_context: "ApplicationContext | None" = None,
+ **kwargs,
+ ) -> None:
+ self.name: str = name or self.__class__.__name__
+ self.backend: str = backend
+ self.app_context: "ApplicationContext | None" = app_context
+ self.kwargs: dict = dict(kwargs)
+ self.logger = get_logger()
+ if hasattr(self.logger, "bind"):
+ self.logger = self.logger.bind(component=self.name)
+
+ self._is_started: bool = False
+ self._lock: asyncio.Lock = asyncio.Lock()
+ # Components created from bind() default_factory in standalone mode (auto-managed lifecycle).
+ self._owned: list["BaseComponent"] = []
+
+ @property
+ def is_started(self) -> bool:
+ """Whether the component has been started."""
+ return self._is_started
+
+ # ----- Dependency declaration ----------------------------------------
+
+ @staticmethod
+ def bind(
+ name: str | None,
+ base_cls: type[T],
+ *,
+ default_factory: Callable[[], T] | None = None,
+ optional: bool = True,
+ ) -> T | None:
+ """Declare a dependency on another component; resolved at start(). Empty name → None."""
+ if not name:
+ return None
+ ctype = getattr(base_cls, "component_type", None)
+ if not isinstance(ctype, ComponentEnum) or ctype is ComponentEnum.BASE:
+ raise TypeError(f"{base_cls.__name__} must declare a non-BASE ComponentEnum 'component_type'")
+ return cast(T, Dependency(ctype, name, default_factory, optional))
+
+ @property
+ def dependencies(self) -> list[Dependency]:
+ """All unresolved bindings declared on this instance."""
+ return [v for v in self.__dict__.values() if isinstance(v, Dependency)]
+
+ async def _resolve_bindings(self) -> None:
+ """Replace Dependency placeholders with real components (or default_factory / None for optional)."""
+ for attr, value in list(self.__dict__.items()):
+ if not isinstance(value, Dependency):
+ continue
+ if self.app_context is None:
+ # Standalone mode: factory or (optional → None) or keep placeholder.
+ if value.default_factory is not None:
+ instance = value.default_factory()
+ setattr(self, attr, instance)
+ if isinstance(instance, BaseComponent):
+ self._owned.append(instance)
+ elif value.optional:
+ setattr(self, attr, None)
+ else:
+ target = self.app_context.components.get(value.ctype, {}).get(value.name)
+ if target is not None:
+ setattr(self, attr, target)
+ elif value.optional:
+ setattr(self, attr, None)
+ else:
+ raise ValueError(f"{value.ctype.value} '{value.name}' not found.")
+
+ # ----- Lookup --------------------------------------------------------
+
+ @property
+ def working_path(self) -> Path:
+ """Resolved working directory from app context or cwd."""
+ if self.app_context is None:
+ return Path.cwd()
+ return Path(self.app_context.app_config.working_dir)
+
+ @property
+ def working_metadata_path(self) -> Path:
+ """Resolved metadata directory: working_path / metadata_dir, or absolute metadata_dir."""
+ if self.app_context is None:
+ return Path.cwd() / "metadata"
+ return self.working_path / self.app_context.app_config.metadata_dir
+
+ # ----- Lifecycle -----------------------------------------------------
+
+ async def _start(self) -> None:
+ """Subclass hook: start logic."""
+
+ async def _close(self) -> None:
+ """Subclass hook: close logic."""
+
+ async def dump(self) -> None:
+ """Persist in-memory state to disk. Override in subclasses that need persistence."""
+
+ async def load(self) -> None:
+ """Restore in-memory state from disk. Override in subclasses that need persistence."""
+
+ async def start(self) -> None:
+ """Resolve bindings → start owned fallbacks → _start(). No-op if already started."""
+ async with self._lock:
+ if self._is_started:
+ return
+ await self._resolve_bindings()
+ for owned in self._owned:
+ await owned.start()
+ await self._start()
+ self._is_started = True
+
+ async def close(self) -> None:
+ """_close() → close owned fallbacks in reverse. No-op if not started."""
+ async with self._lock:
+ if not self._is_started:
+ return
+ await self._close()
+ for owned in reversed(self._owned):
+ await owned.close()
+ self._is_started = False
+
+ async def restart(self) -> None:
+ """Close then start."""
+ await self.close()
+ await self.start()
+
+ async def __call__(self, **kwargs):
+ raise NotImplementedError
+
+ async def __aenter__(self) -> "BaseComponent":
+ await self.start()
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
+ await self.close()
diff --git a/reme4/components/client/__init__.py b/reme4/components/client/__init__.py
new file mode 100644
index 00000000..5c212bac
--- /dev/null
+++ b/reme4/components/client/__init__.py
@@ -0,0 +1,7 @@
+"""Client components."""
+
+from .base_client import BaseClient
+from .http_client import HttpClient
+from .mcp_client import MCPClient
+
+__all__ = ["BaseClient", "HttpClient", "MCPClient"]
diff --git a/reme4/components/client/base_client.py b/reme4/components/client/base_client.py
new file mode 100644
index 00000000..a5b05b6c
--- /dev/null
+++ b/reme4/components/client/base_client.py
@@ -0,0 +1,41 @@
+"""Base client abstraction."""
+
+import json
+from abc import abstractmethod
+from collections.abc import AsyncGenerator
+
+from ..base_component import BaseComponent
+from ...enumeration import ComponentEnum
+
+
+class BaseClient(BaseComponent):
+ """Abstract base for clients that communicate with ReMe services."""
+
+ component_type = ComponentEnum.CLIENT
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.client = None
+
+ async def _start(self) -> None:
+ """Initialize the client."""
+
+ async def _close(self) -> None:
+ """Close the client and release resources."""
+
+ @abstractmethod
+ def _execute(self) -> AsyncGenerator[str, None]:
+ """Backend-specific execution; yield text chunks (single yield for non-streaming backends)."""
+
+ @abstractmethod
+ async def list_actions(self) -> list[dict]:
+ """Discover available actions on the server; each dict is the raw backend descriptor."""
+
+ async def __call__(self) -> AsyncGenerator[str, None]:
+ """Dispatch: action='list' returns the action catalog; otherwise delegate to _execute()."""
+ if getattr(self, "action", None) == "list":
+ actions = await self.list_actions()
+ yield json.dumps(actions, indent=2, ensure_ascii=False)
+ return
+ async for chunk in self._execute():
+ yield chunk
diff --git a/reme4/components/client/http_client.py b/reme4/components/client/http_client.py
new file mode 100644
index 00000000..efec1ff7
--- /dev/null
+++ b/reme4/components/client/http_client.py
@@ -0,0 +1,143 @@
+"""HTTP client for ReMe services."""
+
+import json
+import os
+from collections.abc import AsyncGenerator
+
+import httpx
+
+from .base_client import BaseClient
+from ..component_registry import R
+from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT
+from ...enumeration import ChunkEnum
+from ...schema import StreamChunk
+
+
+@R.register("http")
+class HttpClient(BaseClient):
+ """HTTP client that auto-adapts to JSON or SSE endpoints via Content-Type."""
+
+ def __init__(
+ self,
+ action: str,
+ host: str | None = None,
+ port: int | None = None,
+ timeout: float = 30.0,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ # Resolve host/port: explicit args > env var > defaults
+ if not (host and port):
+ if service_info := os.environ.get(REME_SERVICE_INFO):
+ try:
+ data = json.loads(service_info)
+ host = data["host"]
+ port = data["port"]
+ except Exception:
+ self.logger.warning(f"Invalid service info: {service_info}")
+ host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
+ else:
+ host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
+
+ self.action = action
+ self.base_url = f"http://{host}:{port}"
+ self.timeout = timeout
+
+ async def _start(self) -> None:
+ """Initialize the HTTP client."""
+ if self.client is None:
+ self.client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
+
+ async def _iter_stream_chunks(self) -> AsyncGenerator[StreamChunk, None]:
+ """Send request and yield raw StreamChunks; auto-detects JSON vs SSE via Content-Type.
+
+ For JSON responses: yields a single CONTENT chunk with the raw response body.
+ For SSE responses: yields each streaming chunk as it arrives.
+ """
+ if self.client is None:
+ raise RuntimeError("Client not initialized. Call _start() first.")
+
+ async with self.client.stream("POST", f"/{self.action}", json=self.kwargs) as resp:
+ resp.raise_for_status()
+ ctype = resp.headers.get("content-type", "")
+
+ if ctype.startswith("text/event-stream"):
+ async for line in resp.aiter_lines():
+ if not line.startswith("data:"):
+ continue
+ payload = line[len("data:") :]
+ if payload.strip() == "[DONE]":
+ return
+ try:
+ data = json.loads(payload)
+ except json.JSONDecodeError:
+ continue
+ chunk = StreamChunk(**data)
+ if chunk.chunk_type == ChunkEnum.ERROR:
+ # Surface server-side errors as exceptions so callers don't
+ # mistake error chunks for valid content.
+ raise RuntimeError(str(chunk.chunk))
+ if chunk.done:
+ return
+ yield chunk
+ else:
+ body = await resp.aread()
+ yield StreamChunk(chunk_type=ChunkEnum.CONTENT, chunk=body.decode())
+
+ async def stream_chunks(self) -> AsyncGenerator[StreamChunk, None]:
+ """HTTP-specific richer access: yield raw StreamChunk objects (no display formatting)."""
+ async for chunk in self._iter_stream_chunks():
+ yield chunk
+
+ async def list_actions(self) -> list[dict]:
+ """Return raw OpenAPI operations; each dict gets an `action` key (path without leading '/')."""
+ if self.client is None:
+ raise RuntimeError("Client not initialized. Call _start() first.")
+ resp = await self.client.get("/openapi.json")
+ resp.raise_for_status()
+ spec = resp.json()
+ actions: list[dict] = []
+ for path, methods in spec.get("paths", {}).items():
+ for method, op in methods.items():
+ actions.append({"action": path.lstrip("/"), "method": method.upper(), **op})
+ return actions
+
+ @staticmethod
+ def _format_for_display(text: str) -> str:
+ """Render a JSON response as human-friendly CLI text; pass through unrecognized payloads."""
+ try:
+ data = json.loads(text)
+ except (ValueError, json.JSONDecodeError):
+ return text
+ if not (isinstance(data, dict) and isinstance(data.get("answer"), str)):
+ return json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, (dict, list)) else text
+ d = dict(data)
+ answer = d.pop("answer")
+ success = d.pop("success", None)
+ metadata = d.pop("metadata", None)
+ parts = [answer]
+ status_pieces = []
+ if success is not None:
+ status_pieces.append("✅" if success else "❌")
+ if metadata:
+ status_pieces.append(json.dumps(metadata, ensure_ascii=False))
+ if status_pieces:
+ parts.append(" ".join(status_pieces))
+ if d:
+ parts.append(json.dumps(d, indent=2, ensure_ascii=False))
+ return "\n".join(parts)
+
+ # pylint: disable=invalid-overridden-method
+ async def _execute(self) -> AsyncGenerator[str, None]:
+ """Yield text chunks for CLI display; JSON responses are pretty-formatted."""
+ async for chunk in self._iter_stream_chunks():
+ payload = chunk.chunk
+ text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
+ yield self._format_for_display(text)
+
+ async def _close(self) -> None:
+ """Close the HTTP client."""
+ if self.client is not None:
+ await self.client.aclose()
+ self.client = None
diff --git a/reme4/components/client/mcp_client.py b/reme4/components/client/mcp_client.py
new file mode 100644
index 00000000..b8b17a9b
--- /dev/null
+++ b/reme4/components/client/mcp_client.py
@@ -0,0 +1,125 @@
+"""MCP client for ReMe services."""
+
+import json
+import os
+from collections.abc import AsyncGenerator
+from typing import Any
+
+from fastmcp import Client
+from fastmcp.client import SSETransport, StdioTransport, StreamableHttpTransport
+from fastmcp.client.client import CallToolResult
+
+from .base_client import BaseClient
+from ..component_registry import R
+from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT
+
+_TRANSPORT_MAP = {
+ "sse": SSETransport,
+ "stdio": StdioTransport,
+ "streamable-http": StreamableHttpTransport,
+}
+
+
+@R.register("mcp")
+class MCPClient(BaseClient):
+ """MCP client that communicates with ReMe MCP service via fastmcp.Client.
+
+ Usage:
+ # SSE (default)
+ client = MCPClient(action="my_tool", host="localhost", port=8000, query="hello")
+ async with client:
+ async for text in client():
+ print(text)
+
+ # Streamable HTTP
+ client = MCPClient(action="my_tool", transport="streamable-http", host="localhost", port=8000)
+
+ # Stdio
+ client = MCPClient(action="my_tool", transport="stdio", command="python", args=["server.py"])
+
+ # Custom transport object
+ from fastmcp.client import SSETransport
+ client = MCPClient(action="my_tool", transport=SSETransport(url="http://host:port/sse"))
+ """
+
+ def __init__(
+ self,
+ action: str,
+ transport: str | Any = "sse",
+ host: str | None = None,
+ port: int | None = None,
+ timeout: float = 30.0,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ if isinstance(transport, str) and transport not in _TRANSPORT_MAP:
+ raise ValueError(f"Unknown transport: {transport!r}, expected one of {list(_TRANSPORT_MAP)}")
+
+ if isinstance(transport, str) and transport != "stdio":
+ if not (host and port):
+ if service_info := os.environ.get(REME_SERVICE_INFO):
+ try:
+ data = json.loads(service_info)
+ host = data["host"]
+ port = data["port"]
+ except Exception:
+ self.logger.warning(f"Invalid service info: {service_info}")
+ host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
+ else:
+ host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
+ self.host = host
+ self.port = port
+
+ self.action = action
+ self.transport = transport
+ self.timeout = timeout
+
+ def _build_transport(self):
+ if not isinstance(self.transport, str):
+ return self.transport
+
+ cls = _TRANSPORT_MAP[self.transport]
+
+ if self.transport == "stdio":
+ command = self.kwargs.pop("command", "")
+ args = self.kwargs.pop("args", [])
+ return cls(command=command, args=args)
+
+ path = "/sse" if self.transport == "sse" else "/mcp"
+ url = f"http://{self.host}:{self.port}{path}"
+ return cls(url=url)
+
+ # pylint: disable=unnecessary-dunder-call
+ async def _start(self) -> None:
+ if self.client is None:
+ self.client = Client(self._build_transport(), timeout=self.timeout)
+ await self.client.__aenter__()
+
+ # pylint: disable=invalid-overridden-method
+ async def _execute(self) -> AsyncGenerator[str, None]:
+ if self.client is None:
+ raise RuntimeError("Client not initialized. Call _start() first.")
+
+ result: CallToolResult = await self.client.call_tool(self.action, self.kwargs)
+ yield self._extract_text(result)
+
+ async def list_actions(self) -> list[dict]:
+ """Return raw MCP Tool dumps; each dict gets an `action` key (the tool name)."""
+ if self.client is None:
+ raise RuntimeError("Client not initialized. Call _start() first.")
+ tools = await self.client.list_tools()
+ return [tool.model_dump() for tool in tools]
+
+ # pylint: disable=unnecessary-dunder-call
+ async def _close(self) -> None:
+ if self.client is not None:
+ await self.client.__aexit__(None, None, None)
+ self.client = None
+
+ @staticmethod
+ def _extract_text(result: CallToolResult) -> str:
+ for block in result.content:
+ if hasattr(block, "text"):
+ return block.text
+ return str(result.content)
diff --git a/reme4/components/component_registry.py b/reme4/components/component_registry.py
new file mode 100644
index 00000000..c78ceb9c
--- /dev/null
+++ b/reme4/components/component_registry.py
@@ -0,0 +1,77 @@
+"""Global registry mapping (ComponentEnum, name) -> component class."""
+
+from typing import Callable, TypeVar, cast
+
+from .base_component import BaseComponent
+from ..enumeration import ComponentEnum
+from ..utils import get_logger
+
+T = TypeVar("T", bound=BaseComponent)
+
+
+class ComponentRegistry:
+ """Two-level registry: component_type -> name -> class.
+
+ Supports both direct calls — ``R.register(MyClass, "name")`` — and
+ decorator usage — ``@R.register("name")``.
+ """
+
+ def __init__(self) -> None:
+ self._registry: dict[ComponentEnum, dict[str, type[BaseComponent]]] = {}
+ self.logger = get_logger()
+
+ def _do_register(self, cls: type[T], name: str) -> type[T]:
+ """Insert `cls` under its `component_type` group; warn on overwrite."""
+ component_type = getattr(cls, "component_type", None)
+ if not isinstance(component_type, ComponentEnum):
+ raise TypeError(f"{cls.__name__} must have a ComponentEnum 'component_type' attribute")
+ if not name:
+ raise ValueError("Component name cannot be empty")
+
+ group = self._registry.setdefault(component_type, {})
+ if name in group:
+ self.logger.warning(f"Component '{name}' already registered for {component_type}, overwriting")
+ group[name] = cls
+ return cls
+
+ def register(
+ self,
+ cls_or_name: type[T] | str,
+ name: str | None = None,
+ ) -> Callable[[type[T]], type[T]] | type[T]:
+ """Register a component class directly, or return a decorator that does so."""
+ # Direct mode: first arg is the class itself.
+ if isinstance(cls_or_name, type):
+ return self._do_register(cast(type[T], cls_or_name), name if name is not None else cls_or_name.__name__)
+
+ # Decorator mode: first arg is the registration name.
+ if not isinstance(cls_or_name, str):
+ raise TypeError(f"Expected a class or string, got {type(cls_or_name).__name__}")
+
+ def decorator(decorated_cls: type[T]) -> type[T]:
+ return self._do_register(decorated_cls, cls_or_name)
+
+ return decorator
+
+ def get(self, component_type: ComponentEnum, name: str) -> type[BaseComponent] | None:
+ """Look up a registered class; return None if not found."""
+ return self._registry.get(component_type, {}).get(name)
+
+ def get_all(self, component_type: ComponentEnum) -> dict[str, type[BaseComponent]]:
+ """Return a shallow copy of all classes registered under `component_type`."""
+ return dict(self._registry.get(component_type, {}))
+
+ def unregister(self, component_type: ComponentEnum, name: str) -> bool:
+ """Remove an entry; return True if it existed, False otherwise."""
+ if (group := self._registry.get(component_type)) and name in group:
+ del group[name]
+ return True
+ return False
+
+ def clear(self) -> None:
+ """Drop every registered entry."""
+ self._registry.clear()
+
+
+# Process-wide singleton used throughout the codebase.
+R = ComponentRegistry()
diff --git a/reme4/components/embedding/__init__.py b/reme4/components/embedding/__init__.py
new file mode 100644
index 00000000..bf2d4b48
--- /dev/null
+++ b/reme4/components/embedding/__init__.py
@@ -0,0 +1,6 @@
+"""Embedding model implementations."""
+
+from .base_embedding_model import BaseEmbeddingModel
+from .openai_embedding_model import OpenAIEmbeddingModel
+
+__all__ = ["BaseEmbeddingModel", "OpenAIEmbeddingModel"]
diff --git a/reme4/components/embedding/base_embedding_model.py b/reme4/components/embedding/base_embedding_model.py
new file mode 100644
index 00000000..0d03ec61
--- /dev/null
+++ b/reme4/components/embedding/base_embedding_model.py
@@ -0,0 +1,214 @@
+"""Base embedding model with LRU cache and disk persistence."""
+
+import asyncio
+import hashlib
+import os
+from abc import abstractmethod
+from collections import OrderedDict
+from pathlib import Path
+
+import numpy as np
+
+from ..base_component import BaseComponent
+from ...enumeration import ComponentEnum
+from ...schema import EmbNode
+
+
+class BaseEmbeddingModel(BaseComponent):
+ """Embedding model with LRU cache and disk persistence."""
+
+ component_type = ComponentEnum.EMBEDDING_MODEL
+
+ def __init__(
+ self,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ model_name: str = "",
+ dimensions: int = 1024,
+ pass_dimensions: bool = False,
+ max_batch_size: int = 10,
+ max_input_length: int = 8192,
+ max_cache_size: int = 10000,
+ enable_cache: bool = True,
+ cache_version: str = "v1",
+ max_retries: int = 3,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.api_key = api_key or os.environ.get("EMBEDDING_API_KEY", "")
+ self.base_url = base_url or os.environ.get("EMBEDDING_BASE_URL", "")
+ self.model_name = model_name
+ self.dimensions = dimensions
+ self.pass_dimensions = pass_dimensions
+ self.max_batch_size = max_batch_size
+ self.max_input_length = max_input_length
+ self.max_cache_size = max_cache_size
+ self.enable_cache = enable_cache
+ self.cache_version = cache_version
+ self.max_retries = max_retries
+ self._embedding_cache: OrderedDict[str, np.ndarray] = OrderedDict()
+ self.is_healthy: bool = True
+
+ @property
+ def cache_path(self) -> Path:
+ """Disk path for the embedding cache file."""
+ return self.working_metadata_path / "embedding_cache" / f"{self.name}_{self.cache_version}.npz"
+
+ async def _start(self) -> None:
+ """Load cache from disk on startup."""
+ await self.load()
+
+ async def health_check(self, timeout: float = 2.0) -> bool:
+ """Probe the provider; sets and returns is_healthy."""
+ tag = f"[EMBEDDING HEALTH CHECK] name={self.name} model={self.model_name}"
+ try:
+ result = await asyncio.wait_for(self._get_embeddings(["ping"]), timeout=timeout)
+ if not result or result[0] is None:
+ raise RuntimeError("empty embedding")
+ self.is_healthy = True
+ self.logger.info(f"{tag} -> OK")
+ except asyncio.TimeoutError:
+ self.is_healthy = False
+ self.logger.error(f"{tag} -> FAIL timeout({timeout}s)")
+ except Exception as e:
+ self.is_healthy = False
+ self.logger.error(f"{tag} -> FAIL {type(e).__name__}: {e}")
+ return self.is_healthy
+
+ async def _close(self) -> None:
+ """Persist cache to disk on shutdown."""
+ await self.dump()
+
+ # -- Public API --
+
+ async def get_embedding(self, input_text: str, **kwargs) -> np.ndarray | None:
+ """Get embedding for a single text."""
+ results = await self.get_embeddings([input_text], **kwargs)
+ return results[0] if results else None
+
+ async def get_embeddings(self, input_text: list[str], **kwargs) -> list[np.ndarray | None]:
+ """Get embeddings for a list of texts, with caching and batching."""
+ truncated = [t[: self.max_input_length] for t in input_text]
+ results: list[np.ndarray | None] = [None] * len(truncated)
+ to_compute: list[tuple[int, str]] = []
+
+ # Split into cache hits and misses
+ for idx, text in enumerate(truncated):
+ cached = self._get_from_cache(text)
+ if cached is not None:
+ results[idx] = cached
+ else:
+ to_compute.append((idx, text))
+
+ # Batch-compute misses with retry
+ if to_compute:
+ for i in range(0, len(to_compute), self.max_batch_size):
+ batch = to_compute[i : i + self.max_batch_size]
+ indices = [idx for idx, _ in batch]
+ texts = [text for _, text in batch]
+
+ embeddings = None
+ for attempt in range(self.max_retries):
+ try:
+ embeddings = await self._get_embeddings(texts, **kwargs)
+ if embeddings and len(embeddings) == len(texts):
+ break
+ except (TimeoutError, ConnectionError, OSError):
+ if attempt < self.max_retries - 1:
+ await asyncio.sleep(2**attempt)
+ except Exception:
+ self.logger.exception("Embedding request failed")
+ break
+
+ if not embeddings or len(embeddings) != len(texts):
+ continue
+
+ # Normalize dimensions and cache
+ for orig_idx, text, emb in zip(indices, texts, embeddings):
+ if emb is None:
+ continue
+ emb_array = np.asarray(emb, dtype=np.float16)
+ if len(emb_array) != self.dimensions:
+ if len(emb_array) < self.dimensions:
+ emb_array = np.pad(emb_array, (0, self.dimensions - len(emb_array)))
+ else:
+ emb_array = emb_array[: self.dimensions]
+ results[orig_idx] = emb_array
+ self._put_to_cache(text, emb_array)
+
+ return results
+
+ async def get_node_embeddings(self, nodes: list[EmbNode], **kwargs) -> list[EmbNode]:
+ """Compute and assign embeddings for EmbNode objects."""
+ embeddings = await self.get_embeddings([n.text for n in nodes], **kwargs)
+ if len(embeddings) == len(nodes):
+ for node, vec in zip(nodes, embeddings):
+ if vec is not None:
+ node.embedding = vec
+ return nodes
+
+ @abstractmethod
+ async def _get_embeddings(self, input_text: list[str], **kwargs) -> list[list[float] | None]:
+ """Get raw embeddings from the underlying provider."""
+
+ # -- Cache Operations --
+
+ def _get_from_cache(self, text: str) -> np.ndarray | None:
+ """Lookup text in LRU cache, promoting on hit."""
+ if not self.enable_cache:
+ return None
+ key = self._get_cache_key(text)
+ if key not in self._embedding_cache:
+ return None
+ self._embedding_cache.move_to_end(key)
+ return self._embedding_cache[key]
+
+ def _put_to_cache(self, text: str, embedding: np.ndarray) -> None:
+ """Insert into LRU cache, evicting oldest if full."""
+ if not self.enable_cache or self.max_cache_size <= 0 or len(embedding) != self.dimensions:
+ return
+ key = self._get_cache_key(text)
+ if len(self._embedding_cache) >= self.max_cache_size and key not in self._embedding_cache:
+ self._embedding_cache.popitem(last=False)
+ self._embedding_cache[key] = embedding
+ self._embedding_cache.move_to_end(key)
+
+ def _get_cache_key(self, text: str) -> str:
+ """Generate cache key from text, model name, and dimensions."""
+ return hashlib.sha256(f"{text}|{self.model_name}|{self.dimensions}".encode()).hexdigest()
+
+ # -- Cache Persistence --
+
+ async def load(self) -> None:
+ """Load cached embeddings from disk (npz format); replaces in-memory cache."""
+ self._embedding_cache.clear()
+ if not self.enable_cache or not self.cache_path.exists():
+ return
+
+ try:
+ data = np.load(self.cache_path)
+ except Exception:
+ self.logger.exception("Failed to load embedding cache, removing")
+ self.cache_path.unlink(missing_ok=True)
+ return
+
+ for key, emb in zip(data["keys"], data["embeddings"]):
+ if len(emb) != self.dimensions:
+ continue
+ if len(self._embedding_cache) >= self.max_cache_size:
+ break
+ self._embedding_cache[str(key)] = emb.astype(np.float16)
+ self.logger.info(f"Loaded {len(self._embedding_cache)} embeddings from {self.cache_path}")
+
+ async def dump(self) -> None:
+ """Persist in-memory cache to disk (npz format)."""
+ if not self.enable_cache or not self._embedding_cache:
+ return
+ self.cache_path.parent.mkdir(parents=True, exist_ok=True)
+ keys = list(self._embedding_cache.keys())
+ embeddings = np.stack(list(self._embedding_cache.values()))
+ try:
+ np.savez(self.cache_path, keys=np.array(keys, dtype=str), embeddings=embeddings)
+ self.logger.info(f"Saved {len(self._embedding_cache)} embeddings to {self.cache_path}")
+ except Exception:
+ self.logger.exception("Failed to save embedding cache")
diff --git a/reme4/components/embedding/openai_embedding_model.py b/reme4/components/embedding/openai_embedding_model.py
new file mode 100644
index 00000000..a03c4a01
--- /dev/null
+++ b/reme4/components/embedding/openai_embedding_model.py
@@ -0,0 +1,52 @@
+"""OpenAI-compatible async embedding model."""
+
+from openai import AsyncOpenAI
+
+from .base_embedding_model import BaseEmbeddingModel
+from ..component_registry import R
+
+
+@R.register("openai")
+class OpenAIEmbeddingModel(BaseEmbeddingModel):
+ """Embedding model backed by any OpenAI-compatible API."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._client: AsyncOpenAI | None = None
+
+ async def _start(self) -> None:
+ """Initialize async OpenAI client."""
+ self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, **self.kwargs)
+ await super()._start()
+
+ async def _close(self) -> None:
+ """Close the async OpenAI client."""
+ if self._client:
+ await self._client.close()
+ self._client = None
+ await super()._close()
+
+ async def _get_embeddings(self, input_text: list[str], **kwargs) -> list[list[float] | None]:
+ """Call the embeddings API and return results aligned to input order."""
+ if self._client is None:
+ raise RuntimeError("Client not initialized. Call _start() first.")
+
+ create_kwargs: dict = {"model": self.model_name, "input": input_text, **kwargs}
+ if self.pass_dimensions:
+ create_kwargs["dimensions"] = self.dimensions
+
+ completion = await self._client.embeddings.create(**create_kwargs)
+
+ # Map API results back to input order
+ result: list[list[float] | None] = [None] * len(input_text)
+ for emb in completion.data:
+ if 0 <= emb.index < len(input_text):
+ vec = emb.embedding or getattr(emb, "dense_embedding", None)
+ if vec is not None:
+ result[emb.index] = list(vec)
+ else:
+ self.logger.warning(f"Empty embedding at index {emb.index}")
+ else:
+ self.logger.warning(f"Index {emb.index} out of range for input length {len(input_text)}")
+
+ return result
diff --git a/reme4/components/file_graph/__init__.py b/reme4/components/file_graph/__init__.py
new file mode 100644
index 00000000..12b01355
--- /dev/null
+++ b/reme4/components/file_graph/__init__.py
@@ -0,0 +1,7 @@
+"""File graph module."""
+
+from .base_file_graph import BaseFileGraph
+from .local_file_graph import LocalFileGraph
+from .nx_file_graph import NxFileGraph
+
+__all__ = ["BaseFileGraph", "LocalFileGraph", "NxFileGraph"]
diff --git a/reme4/components/file_graph/base_file_graph.py b/reme4/components/file_graph/base_file_graph.py
new file mode 100644
index 00000000..333d75db
--- /dev/null
+++ b/reme4/components/file_graph/base_file_graph.py
@@ -0,0 +1,53 @@
+"""Abstract base for file-graph backends."""
+
+from abc import abstractmethod
+from pathlib import Path
+
+from ..base_component import BaseComponent
+from ...enumeration import ComponentEnum
+from ...schema import FileLink, FileNode
+
+
+class BaseFileGraph(BaseComponent):
+ """Abstract base for file-graph backends."""
+
+ component_type = ComponentEnum.FILE_GRAPH
+
+ def __init__(self, graph_name: str = "default", graph_version: str = "v1", **kwargs):
+ super().__init__(**kwargs)
+ self.graph_name: str = graph_name or self.name
+ self.graph_version: str = graph_version
+ self.graph_path: Path = self.working_metadata_path / self.component_type.value
+ self.graph_path.mkdir(parents=True, exist_ok=True)
+
+ # -- Node CRUD ---------------------------------------------------------
+
+ @abstractmethod
+ async def upsert_nodes(self, nodes: list[FileNode]) -> None:
+ """Insert or update nodes in the graph."""
+
+ @abstractmethod
+ async def delete_nodes(self, paths: list[str]) -> None:
+ """Delete nodes by path."""
+
+ @abstractmethod
+ async def get_nodes(self, paths: list[str] | None = None) -> list[FileNode]:
+ """Return nodes by paths; None = all real nodes; [] = []."""
+
+ @abstractmethod
+ async def rebuild_links(self) -> None:
+ """Rebuild all edges from each node's link payload."""
+
+ @abstractmethod
+ async def clear(self):
+ """Remove all nodes and edges."""
+
+ # -- Link access -------------------------------------------------------
+
+ @abstractmethod
+ async def get_outlinks(self, path: str) -> list[FileLink]:
+ """Return outgoing links for *path*."""
+
+ @abstractmethod
+ async def get_inlinks(self, path: str) -> list[FileLink]:
+ """Return incoming links for *path*."""
diff --git a/reme4/components/file_graph/local_file_graph.py b/reme4/components/file_graph/local_file_graph.py
new file mode 100644
index 00000000..e68ef46f
--- /dev/null
+++ b/reme4/components/file_graph/local_file_graph.py
@@ -0,0 +1,138 @@
+"""Pure-Python file-graph backend (no external deps)."""
+
+from pathlib import Path
+
+from .base_file_graph import BaseFileGraph
+from ..component_registry import R
+from ...schema import FileLink, FileNode
+
+
+@R.register("local")
+class LocalFileGraph(BaseFileGraph):
+ """Dict-backed file graph; uses FileLink.target_path for adjacency."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._nodes: dict[str, FileNode] = {}
+ self._inverse: dict[str, set[str]] = {} # target → {sources}
+ self._pending: dict[str, set[str]] = {} # virtual target → {sources}
+ self._graph_file: Path = self.graph_path / f"{self.graph_name}_{self.graph_version}.jsonl"
+
+ # -- Lifecycle ---------------------------------------------------------
+
+ async def _start(self) -> None:
+ await super()._start()
+ await self.load()
+ await self.rebuild_links()
+
+ async def _close(self) -> None:
+ await self.dump()
+ await super()._close()
+
+ async def load(self) -> None:
+ """Load nodes from JSONL file into memory; keep current state on failure."""
+ if not self._graph_file.exists():
+ return
+ try:
+ with open(self._graph_file, "r", encoding="utf-8") as f:
+ self._nodes.update(
+ (n.path, n) for line in f if line.strip() for n in [FileNode.model_validate_json(line)]
+ )
+ self.logger.info(f"Loaded {len(self._nodes)} nodes from {self._graph_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to load {self._graph_file}: {e}")
+
+ async def dump(self) -> None:
+ """Persist all nodes to JSONL via atomic rename."""
+ try:
+ tmp = self._graph_file.with_suffix(".tmp")
+ with open(tmp, "w", encoding="utf-8") as f:
+ f.writelines(f"{n.model_dump_json()}\n" for n in self._nodes.values())
+ tmp.replace(self._graph_file)
+ self.logger.info(f"Saved {len(self._nodes)} nodes to {self._graph_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to write {self._graph_file}: {e}")
+
+ # -- Edge bookkeeping --------------------------------------------------
+
+ def _add_edge(self, src: str, target: str) -> None:
+ """Register src→target; route to pending if target is virtual."""
+ bucket = self._inverse if target in self._nodes else self._pending
+ bucket.setdefault(target, set()).add(src)
+
+ def _remove_edge(self, src: str, target: str) -> None:
+ """Remove src→target from both inverse and pending buckets."""
+ for bucket in (self._inverse, self._pending):
+ srcs = bucket.get(target)
+ if srcs is None or src not in srcs:
+ continue
+ srcs.discard(src)
+ if not srcs:
+ del bucket[target]
+
+ # -- Node CRUD ---------------------------------------------------------
+
+ async def upsert_nodes(self, nodes: list[FileNode]) -> None:
+ for node in nodes:
+ path = node.path
+ old = self._nodes.get(path)
+ if old is not None:
+ for link in old.links:
+ if link.target_path:
+ self._remove_edge(path, link.target_path)
+ self._nodes[path] = node
+ for link in node.links:
+ if link.target_path:
+ self._add_edge(path, link.target_path)
+ # Promote pending edges that now target a real node.
+ promoted = self._pending.pop(path, None)
+ if promoted:
+ self._inverse.setdefault(path, set()).update(promoted)
+
+ async def delete_nodes(self, paths: list[str]) -> None:
+ for path in paths:
+ node = self._nodes.pop(path, None)
+ if node is None:
+ continue
+ for link in node.links:
+ if link.target_path:
+ self._remove_edge(path, link.target_path)
+ # Demote inbound edges to pending (sources still reference this path).
+ demoted = self._inverse.pop(path, None)
+ if demoted:
+ self._pending.setdefault(path, set()).update(demoted)
+
+ async def get_nodes(self, paths: list[str] | None = None) -> list[FileNode]:
+ if paths is None:
+ return list(self._nodes.values())
+ return [self._nodes[p] for p in paths if p in self._nodes]
+
+ async def rebuild_links(self) -> None:
+ """Rebuild inverse/pending indexes from all node link payloads."""
+ self._inverse.clear()
+ self._pending.clear()
+ for src, node in self._nodes.items():
+ for link in node.links:
+ if link.target_path:
+ self._add_edge(src, link.target_path)
+
+ async def clear(self):
+ self._nodes.clear()
+ self._inverse.clear()
+ self._pending.clear()
+ self._graph_file.unlink(missing_ok=True)
+
+ # -- Link access -------------------------------------------------------
+
+ async def get_outlinks(self, path: str) -> list[FileLink]:
+ node = self._nodes.get(path)
+ if node is None:
+ return []
+ return [lnk for lnk in node.links if lnk.target_path and lnk.target_path in self._nodes]
+
+ async def get_inlinks(self, path: str) -> list[FileLink]:
+ if path not in self._nodes:
+ return []
+ return [
+ link for src in self._inverse.get(path, ()) for link in self._nodes[src].links if link.target_path == path
+ ]
diff --git a/reme4/components/file_graph/nx_file_graph.py b/reme4/components/file_graph/nx_file_graph.py
new file mode 100644
index 00000000..c082ad04
--- /dev/null
+++ b/reme4/components/file_graph/nx_file_graph.py
@@ -0,0 +1,122 @@
+"""Networkx file-graph backend."""
+
+import pickle
+from pathlib import Path
+
+try:
+ import networkx as nx
+except ImportError:
+ nx = None
+
+from .base_file_graph import BaseFileGraph
+from ..component_registry import R
+from ...schema import FileLink, FileNode
+
+
+@R.register("nx")
+class NxFileGraph(BaseFileGraph):
+ """Networkx-backed file graph; uses FileLink.target_path for adjacency."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ if nx is None:
+ raise ImportError("NxFileGraph requires networkx — pip install networkx")
+ self._graph: nx.MultiDiGraph = nx.MultiDiGraph()
+ self._graph_file: Path = self.graph_path / f"{self.graph_name}_{self.graph_version}.pkl"
+
+ # -- Lifecycle ---------------------------------------------------------
+
+ async def _start(self) -> None:
+ await super()._start()
+ await self.load()
+
+ async def _close(self) -> None:
+ await self.dump()
+ await super()._close()
+
+ async def load(self) -> None:
+ """Load graph from pickle file; keep current graph on failure."""
+ if not self._graph_file.exists():
+ return
+ try:
+ with open(self._graph_file, "rb") as f:
+ self._graph = pickle.load(f)
+ n_real = sum(1 for _, d in self._graph.nodes(data=True) if "node" in d)
+ self.logger.info(f"Loaded {n_real} nodes from {self._graph_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to load {self._graph_file}: {e}")
+
+ async def dump(self) -> None:
+ """Persist graph to pickle via atomic rename."""
+ try:
+ tmp = self._graph_file.with_suffix(".tmp")
+ with open(tmp, "wb") as f:
+ pickle.dump(self._graph, f, protocol=pickle.HIGHEST_PROTOCOL)
+ tmp.replace(self._graph_file)
+ n_real = sum(1 for _, d in self._graph.nodes(data=True) if "node" in d)
+ self.logger.info(f"Saved {n_real} nodes to {self._graph_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to write {self._graph_file}: {e}")
+
+ # -- Node CRUD ---------------------------------------------------------
+
+ async def upsert_nodes(self, nodes: list[FileNode]) -> None:
+ for node in nodes:
+ path = node.path
+ if self._graph.has_node(path):
+ # Drop outgoing edges; inbound stay intact.
+ self._graph.remove_edges_from(list(self._graph.out_edges(path, keys=True)))
+ self._graph.add_node(path, node=node) # promotes virtual node if present
+ # Missing targets become attr-less virtual nodes.
+ self._graph.add_edges_from((path, lnk.target_path, {"link": lnk}) for lnk in node.links if lnk.target_path)
+
+ async def delete_nodes(self, paths: list[str]) -> None:
+ for path in paths:
+ if not self._graph.has_node(path):
+ continue
+ self._graph.remove_edges_from(list(self._graph.out_edges(path, keys=True)))
+ # Demote to virtual: keep inbound edges, drop node payload.
+ self._graph.nodes[path].pop("node", None)
+ if self._graph.in_degree(path) == 0:
+ self._graph.remove_node(path) # remove orphan virtual node
+
+ async def get_nodes(self, paths: list[str] | None = None) -> list[FileNode]:
+ nodes_view = self._graph.nodes
+ if paths is None:
+ return [d["node"] for _, d in nodes_view(data=True) if "node" in d]
+ return [nodes_view[path]["node"] for path in paths if path in nodes_view and "node" in nodes_view[path]]
+
+ async def rebuild_links(self) -> None:
+ """Rebuild all edges from real node payloads; drop virtual nodes."""
+ self._graph.remove_edges_from(list(self._graph.edges(keys=True)))
+ virtual = [n for n, d in self._graph.nodes(data=True) if "node" not in d]
+ self._graph.remove_nodes_from(virtual)
+ self._graph.add_edges_from(
+ (path, lnk.target_path, {"link": lnk})
+ for path, data in self._graph.nodes(data=True)
+ for lnk in data["node"].links
+ if lnk.target_path
+ )
+
+ async def clear(self):
+ """Remove all nodes and edges, and remove persisted file."""
+ self._graph.clear()
+ self._graph_file.unlink(missing_ok=True)
+
+ # -- Link access -------------------------------------------------------
+
+ async def get_outlinks(self, path: str) -> list[FileLink]:
+ nodes_view = self._graph.nodes
+ if path not in nodes_view or "node" not in nodes_view[path]:
+ return []
+ return [
+ d["link"]
+ for _, target, d in self._graph.out_edges(path, data=True)
+ if "link" in d and "node" in nodes_view[target]
+ ]
+
+ async def get_inlinks(self, path: str) -> list[FileLink]:
+ nodes_view = self._graph.nodes
+ if path not in nodes_view or "node" not in nodes_view[path]:
+ return []
+ return [d["link"] for _, _, d in self._graph.in_edges(path, data=True) if "link" in d]
diff --git a/reme4/components/file_parser/__init__.py b/reme4/components/file_parser/__init__.py
new file mode 100644
index 00000000..3e20d0bf
--- /dev/null
+++ b/reme4/components/file_parser/__init__.py
@@ -0,0 +1,7 @@
+"""File parser components."""
+
+from .bare_file_parser import BareFileParser
+from .base_file_parser import BaseFileParser
+from .default_file_parser import DefaultFileParser
+
+__all__ = ["BareFileParser", "BaseFileParser", "DefaultFileParser"]
diff --git a/reme4/components/file_parser/bare_file_parser.py b/reme4/components/file_parser/bare_file_parser.py
new file mode 100644
index 00000000..e0ecef05
--- /dev/null
+++ b/reme4/components/file_parser/bare_file_parser.py
@@ -0,0 +1,22 @@
+"""Stat-only parser for attachment/binary files."""
+
+from pathlib import Path
+
+from .base_file_parser import BaseFileParser
+from ..component_registry import R
+from ...schema import FileChunk, FileNode
+
+
+@R.register("bare")
+class BareFileParser(BaseFileParser):
+ """Stat-only parser for attachment/binary files.
+
+ No content read, no chunking, no link extraction. The resulting FileNode
+ has empty links and chunk_ids; front_matter carries mime and size so
+ retrieval can filter by file type without reopening the file.
+ """
+
+ async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
+ file_path = Path(path)
+ stat = file_path.stat()
+ return FileNode(path=self._get_relative_path(path), st_mtime=stat.st_mtime, links=[], chunk_ids=[]), []
diff --git a/reme4/components/file_parser/base_file_parser.py b/reme4/components/file_parser/base_file_parser.py
new file mode 100644
index 00000000..30595385
--- /dev/null
+++ b/reme4/components/file_parser/base_file_parser.py
@@ -0,0 +1,30 @@
+"""Abstract base for file parsers."""
+
+from abc import abstractmethod
+from pathlib import Path
+
+from ..base_component import BaseComponent
+from ...enumeration import ComponentEnum
+from ...schema import FileChunk, FileNode
+
+
+class BaseFileParser(BaseComponent):
+ """Abstract base for file parsers. Subclasses implement `parse`."""
+
+ component_type = ComponentEnum.FILE_PARSER
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.working_dir = self.app_context.app_config.working_dir if self.app_context else ""
+
+ def _get_relative_path(self, path: str | Path) -> str:
+ """Return path relative to working_dir, or absolute path if outside."""
+ file_path = Path(path).absolute()
+ try:
+ return str(file_path.relative_to(Path(self.working_dir).absolute()))
+ except ValueError:
+ return str(file_path)
+
+ @abstractmethod
+ async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
+ """Parse a file into (node, chunks)."""
diff --git a/reme4/components/file_parser/default_file_parser.py b/reme4/components/file_parser/default_file_parser.py
new file mode 100644
index 00000000..69a7c694
--- /dev/null
+++ b/reme4/components/file_parser/default_file_parser.py
@@ -0,0 +1,123 @@
+"""Default file parser with byte-based overlapping chunking."""
+
+import re
+from bisect import bisect_right
+from pathlib import Path
+
+import aiofiles
+import yaml
+
+from .base_file_parser import BaseFileParser
+from ..component_registry import R
+from ...schema import FileChunk, FileFrontMatter, FileLink, FileNode
+
+# Single-pass wikilink + optional Dataview predicate.
+# Covers: [[X]] / [[X#h]] / [[X|alias]] / pred:: [[X]] / [pred:: [[X]]]
+# - predicate group: optional leading '[' (Dataview inline-bracket form), an identifier,
+# then '::' — the whole prefix is non-capturing-optional so bare wikilinks still match.
+# - target / anchor: target stops before '#', '|', '[', ']'; anchor stops before '|', '[', ']'.
+# - alias '|...': consumed but not captured (we don't need display text).
+_LINK_RE = re.compile(
+ r"(?:\[?\s*(?P[A-Za-z][\w-]*)\s*::\s*)?"
+ r"\[\[\s*(?P[^\[\]|#]+?)"
+ r"(?:#(?P[^\[\]|]+?))?"
+ r"\s*(?:\|[^\[\]]*?)?\s*\]\]",
+)
+
+
+@R.register("default")
+class DefaultFileParser(BaseFileParser):
+ """Parser that splits files into byte-based overlapping chunks."""
+
+ def __init__(self, encoding: str = "utf-8", chunk_byte_size: int = 10000, overlap_byte_size: int = 100, **kwargs):
+ super().__init__(**kwargs)
+ self.encoding = encoding
+ self.chunk_byte_size = max(100, chunk_byte_size)
+ self.overlap_byte_size = max(4, overlap_byte_size)
+
+ @staticmethod
+ def parse_links(content: str, source_path: str) -> list[FileLink]:
+ """Extract wikilinks with optional Dataview predicate as outgoing FileLinks."""
+ links: list[FileLink] = []
+ for m in _LINK_RE.finditer(content):
+ target = m["target"].strip()
+ if not target:
+ continue
+ anchor = m["anchor"]
+ links.append(
+ FileLink(
+ source_path=source_path,
+ target_path=target,
+ target_anchor=anchor.strip() if anchor else None,
+ predicate=m["predicate"],
+ ),
+ )
+ return links
+
+ @staticmethod
+ def _parse_front_matter(text: str) -> tuple[FileFrontMatter, str]:
+ """Parse YAML front matter delimited by ---, return (front_matter, remaining)."""
+ if not text.startswith("---"):
+ return FileFrontMatter(), text
+ end_idx = text.find("\n---", 3)
+ if end_idx == -1:
+ return FileFrontMatter(), text
+ try:
+ data = yaml.safe_load(text[3:end_idx].strip()) or {}
+ front_matter = FileFrontMatter(**(data if isinstance(data, dict) else {}))
+ except yaml.YAMLError:
+ front_matter = FileFrontMatter()
+ return front_matter, text[end_idx + 4 :].lstrip("\n")
+
+ async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
+ file_path = Path(path)
+ stat = file_path.stat()
+ rel_path = self._get_relative_path(path)
+
+ async with aiofiles.open(file_path, encoding=self.encoding) as f:
+ text = await f.read()
+
+ if not text:
+ return FileNode(path=rel_path, st_mtime=stat.st_mtime), []
+
+ front_matter, content = self._parse_front_matter(text)
+ if not content:
+ return FileNode(path=rel_path, st_mtime=stat.st_mtime, front_matter=front_matter), []
+
+ links = self.parse_links(content, rel_path)
+ chunks = self._chunk_content(content, rel_path)
+ chunk_ids = [c.id for c in chunks]
+ return (
+ FileNode(
+ path=rel_path,
+ st_mtime=stat.st_mtime,
+ front_matter=front_matter,
+ links=links,
+ chunk_ids=chunk_ids,
+ ),
+ chunks,
+ )
+
+ def _chunk_content(self, content: str, rel_path: str) -> list[FileChunk]:
+ """Split content into overlapping byte-range chunks with line numbers."""
+ content_bytes = content.encode(self.encoding)
+ newline_positions = [i for i, b in enumerate(content_bytes) if b == ord("\n")]
+ chunks: list[FileChunk] = []
+ step = self.chunk_byte_size - self.overlap_byte_size
+ start = 0
+
+ while start < len(content_bytes):
+ end = min(start + self.chunk_byte_size, len(content_bytes))
+ chunk_text = content_bytes[start:end].decode(self.encoding, errors="ignore")
+ start_line = bisect_right(newline_positions, start - 1) + 1
+ end_line = bisect_right(newline_positions, end - 1) + 1
+ if content_bytes[end - 1] == ord("\n"):
+ end_line -= 1
+ chunks.append(
+ FileChunk(path=rel_path, start_line=start_line, end_line=end_line, text=chunk_text).set_hash_id(),
+ )
+ if end >= len(content_bytes):
+ break
+ start += step
+
+ return chunks
diff --git a/reme4/components/file_store/__init__.py b/reme4/components/file_store/__init__.py
new file mode 100644
index 00000000..7739fb99
--- /dev/null
+++ b/reme4/components/file_store/__init__.py
@@ -0,0 +1,14 @@
+"""File store module.
+
+In-memory + JSONL backend for the (file → chunks) graph. Subclass
+`BaseFileStore` to add other backends; only `LocalFileStore` is
+shipped today.
+"""
+
+from .base_file_store import BaseFileStore
+from .local_file_store import LocalFileStore
+
+__all__ = [
+ "BaseFileStore",
+ "LocalFileStore",
+]
diff --git a/reme4/components/file_store/base_file_store.py b/reme4/components/file_store/base_file_store.py
new file mode 100644
index 00000000..5cc6edd9
--- /dev/null
+++ b/reme4/components/file_store/base_file_store.py
@@ -0,0 +1,100 @@
+"""Abstract base for file store backends."""
+
+from abc import abstractmethod
+
+from ..base_component import BaseComponent
+from ..embedding import BaseEmbeddingModel
+from ..file_graph import BaseFileGraph
+from ..keyword_index import BaseKeywordIndex
+from ...enumeration import ComponentEnum
+from ...schema import FileChunk, FileNode, FileLink
+
+
+class BaseFileStore(BaseComponent):
+ """Abstract base for file store backends."""
+
+ component_type = ComponentEnum.FILE_STORE
+
+ def __init__(
+ self,
+ store_name: str,
+ embedding_model: str = "default",
+ keyword_index: str = "default",
+ file_graph: str = "default",
+ store_version: str = "v1",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ from ..embedding import OpenAIEmbeddingModel
+ from ..file_graph import LocalFileGraph
+ from ..keyword_index import BM25Index
+
+ self.store_name = store_name or self.name
+ self.store_version = store_version
+ if not embedding_model and not keyword_index:
+ raise ValueError("At least one of embedding_model or keyword_index must be set.")
+
+ self.embedding_model = self.bind(embedding_model, BaseEmbeddingModel, default_factory=OpenAIEmbeddingModel)
+ self.keyword_index = self.bind(keyword_index, BaseKeywordIndex, default_factory=BM25Index)
+ self.file_graph = self.bind(file_graph, BaseFileGraph, default_factory=LocalFileGraph)
+ self.store_path = self.working_metadata_path / self.component_type.value / store_name
+ self.store_path.mkdir(parents=True, exist_ok=True)
+
+ async def _start(self) -> None:
+ """Probe embedding model; disable vector capability if it fails."""
+ if self.embedding_model is None:
+ return
+ if not await self.embedding_model.health_check():
+ self.logger.warning(f"{self.store_name}: embedding unhealthy, vector disabled")
+ self.embedding_model = None
+
+ def _disable_embedding(self, reason: str) -> None:
+ """Drop embedding after a runtime failure; keyword search still works."""
+ if self.embedding_model is None:
+ return
+ self.logger.error(f"{self.store_name}: embedding disabled, {reason}")
+ self.embedding_model = None
+
+ async def upsert_file(
+ self,
+ file: tuple[FileNode, list[FileChunk]] | list[tuple[FileNode, list[FileChunk]]],
+ ) -> None:
+ """Upsert a file and its chunks into the store."""
+
+ async def delete_by_path(self, path: str | list[str]) -> None:
+ """Delete files by their paths from the store."""
+
+ async def clear(self):
+ """Clear the store of all files and chunks."""
+
+ @abstractmethod
+ async def vector_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
+ """Perform vector similarity search."""
+
+ @abstractmethod
+ async def keyword_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
+ """Perform full-text keyword search."""
+
+ async def rebuild_links(self) -> None:
+ """Rebuild all edges from each node's link payload."""
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for delete_by_path")
+ return await self.file_graph.rebuild_links()
+
+ async def get_nodes(self, paths: list[str]) -> list[FileNode]:
+ """Return file nodes for the given paths (missing paths are skipped)."""
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for get_nodes")
+ return await self.file_graph.get_nodes(paths)
+
+ async def get_outlinks(self, path: str) -> list[FileLink]:
+ """Return outgoing links for *path*."""
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for delete_by_path")
+ return await self.file_graph.get_outlinks(path)
+
+ async def get_inlinks(self, path: str) -> list[FileLink]:
+ """Return incoming links for *path*."""
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for delete_by_path")
+ return await self.file_graph.get_inlinks(path)
diff --git a/reme4/components/file_store/local_file_store.py b/reme4/components/file_store/local_file_store.py
new file mode 100644
index 00000000..60b920d0
--- /dev/null
+++ b/reme4/components/file_store/local_file_store.py
@@ -0,0 +1,177 @@
+"""In-memory file store with JSONL persistence on close."""
+
+import aiofiles
+import numpy as np
+
+from .base_file_store import BaseFileStore
+from ..component_registry import R
+from ...schema import FileChunk, FileNode
+from ...utils import batch_cosine_similarity
+
+
+@R.register("local")
+class LocalFileStore(BaseFileStore):
+ """In-memory file store with deferred JSONL persistence."""
+
+ def __init__(self, encoding: str = "utf-8", **kwargs):
+ super().__init__(**kwargs)
+ self.encoding = encoding
+ self.file_chunks: dict[str, FileChunk] = {}
+ self.chunks_path = self.store_path / f"file_chunks_{self.store_version}.jsonl"
+
+ # Lifecycle
+
+ async def _start(self) -> None:
+ await super()._start()
+ await self.load()
+
+ async def _close(self) -> None:
+ await self.dump()
+ self.file_chunks.clear()
+ await super()._close()
+
+ async def load(self) -> None:
+ """Load chunks from JSONL file into memory."""
+ if not self.chunks_path.exists():
+ return
+ try:
+ async with aiofiles.open(self.chunks_path, encoding=self.encoding) as f:
+ async for line in f:
+ line = line.strip()
+ if line:
+ chunk = FileChunk.model_validate_json(line)
+ self.file_chunks[chunk.id] = chunk
+ self.logger.info(f"Loaded {len(self.file_chunks)} chunks from {self.chunks_path}")
+ except Exception as e:
+ self.logger.exception(f"Failed to load {self.chunks_path}: {e}")
+
+ async def dump(self) -> None:
+ """Persist chunks to JSONL via atomic rename, then cascade to keyword_index and file_graph."""
+ try:
+ tmp = self.chunks_path.with_suffix(".tmp")
+ async with aiofiles.open(tmp, "w", encoding=self.encoding) as f:
+ await f.write("\n".join(c.model_dump_json() for c in self.file_chunks.values()))
+ tmp.replace(self.chunks_path)
+ self.logger.info(f"Saved {len(self.file_chunks)} chunks to {self.chunks_path}")
+ except Exception as e:
+ self.logger.exception(f"Failed to write {self.chunks_path}: {e}")
+ if self.keyword_index:
+ await self.keyword_index.dump()
+ if self.file_graph:
+ await self.file_graph.dump()
+
+ # Base class interface
+
+ async def upsert_file(
+ self,
+ file: tuple[FileNode, list[FileChunk]] | list[tuple[FileNode, list[FileChunk]]],
+ ) -> None:
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for upsert_file")
+ if isinstance(file, tuple):
+ file = [file]
+
+ old_map = {n.path: n for n in await self.file_graph.get_nodes([node.path for node, _ in file])}
+
+ new_nodes: list[FileNode] = []
+ needs_embed: list[FileChunk] = []
+ keyword_docs: dict[str, str] = {}
+ for node, chunks in file:
+ old_node: FileNode | None = old_map.get(node.path)
+ cached = {}
+ if old_node and self.embedding_model:
+ for cid in old_node.chunk_ids:
+ old = self.file_chunks.pop(cid, None)
+ if old and old.embedding is not None:
+ cached[cid] = old.embedding
+
+ node.chunk_ids = []
+ for c in chunks:
+ if self.embedding_model and c.embedding is None:
+ if c.id in cached:
+ c.embedding = cached[c.id]
+ elif c.text:
+ needs_embed.append(c)
+ node.chunk_ids.append(c.id)
+ self.file_chunks[c.id] = c
+ if c.text:
+ keyword_docs[c.id] = c.text
+ new_nodes.append(node)
+
+ await self.file_graph.upsert_nodes(new_nodes)
+ if needs_embed and self.embedding_model:
+ try:
+ await self.embedding_model.get_node_embeddings(needs_embed)
+ except Exception as e:
+ self._disable_embedding(f"upsert: {type(e).__name__}: {e}")
+ if self.keyword_index and keyword_docs:
+ await self.keyword_index.add_docs(keyword_docs)
+
+ async def delete_by_path(self, path: str | list[str]) -> None:
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for delete_by_path")
+ if isinstance(path, str):
+ path = [path]
+ nodes = await self.file_graph.get_nodes(path)
+ if not nodes:
+ return
+ deleted_chunk_ids = [cid for n in nodes for cid in n.chunk_ids]
+ for cid in deleted_chunk_ids:
+ self.file_chunks.pop(cid, None)
+ await self.file_graph.delete_nodes([n.path for n in nodes])
+ if self.keyword_index and deleted_chunk_ids:
+ await self.keyword_index.delete_docs(deleted_chunk_ids)
+
+ async def clear(self) -> None:
+ if not self.file_graph:
+ raise RuntimeError("file_graph is required for clear")
+ self.file_chunks.clear()
+ self.chunks_path.unlink(missing_ok=True)
+ if self.keyword_index:
+ await self.keyword_index.clear()
+ await self.file_graph.clear()
+
+ # Search
+
+ async def vector_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
+ if self.embedding_model is None or not query:
+ return []
+
+ try:
+ query_embedding = await self.embedding_model.get_embedding(query)
+ except Exception as e:
+ self._disable_embedding(f"search: {type(e).__name__}: {e}")
+ return []
+ if query_embedding is None:
+ return []
+
+ candidates = [c for c in self.file_chunks.values() if c.embedding is not None]
+ if not candidates:
+ return []
+
+ candidate_embeddings = np.stack([c.embedding for c in candidates])
+ similarities = batch_cosine_similarity(query_embedding.reshape(1, -1), candidate_embeddings)[0]
+
+ results = [
+ c.model_copy(update={"scores": {"vector": float(s), "score": float(s)}})
+ for c, s in zip(candidates, similarities)
+ ]
+ results.sort(key=lambda r: r.score, reverse=True)
+ return results[:limit]
+
+ async def keyword_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
+ if not self.keyword_index:
+ return []
+
+ query = query.strip()
+ if not query:
+ return []
+
+ doc_id_score_dict = await self.keyword_index.retrieve(query, limit=limit)
+ results = []
+ for doc_id, score in doc_id_score_dict.items():
+ chunk = self.file_chunks.get(doc_id)
+ if chunk:
+ results.append(chunk.model_copy(update={"scores": {"keyword": score, "score": score}}))
+
+ return results
diff --git a/reme4/components/file_watcher/__init__.py b/reme4/components/file_watcher/__init__.py
new file mode 100644
index 00000000..0b11d995
--- /dev/null
+++ b/reme4/components/file_watcher/__init__.py
@@ -0,0 +1,9 @@
+"""File watcher implementations for monitoring file system changes."""
+
+from .base_file_watcher import BaseFileWatcher
+from .lite_file_watcher import LiteFileWatcher
+
+__all__ = [
+ "BaseFileWatcher",
+ "LiteFileWatcher",
+]
diff --git a/reme4/components/file_watcher/base_file_watcher.py b/reme4/components/file_watcher/base_file_watcher.py
new file mode 100644
index 00000000..3c6fbc9e
--- /dev/null
+++ b/reme4/components/file_watcher/base_file_watcher.py
@@ -0,0 +1,118 @@
+"""Abstract base for file watchers."""
+
+import asyncio
+from abc import abstractmethod
+from pathlib import Path
+
+from watchfiles import Change
+
+from ..base_component import BaseComponent
+from ..file_parser import BaseFileParser
+from ..file_store import BaseFileStore
+from ...enumeration import ComponentEnum
+
+
+class BaseFileWatcher(BaseComponent):
+ """Abstract base for file watchers. Subclasses implement watch_loop and event handlers."""
+
+ component_type = ComponentEnum.FILE_WATCHER
+
+ def __init__(
+ self,
+ watch_paths: list[str] | str,
+ suffix_filters: list[str] | None = None,
+ recursive: bool = True,
+ force_polling: bool = True,
+ debounce: int = 2000,
+ poll_delay_ms: int = 2000,
+ file_store: str = "default",
+ file_parser: str = "default",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ from ..file_parser import DefaultFileParser
+ from ..file_store import LocalFileStore
+
+ watch_paths = [watch_paths] if isinstance(watch_paths, str) else watch_paths
+ base = self.working_path
+ self.watch_paths: list[Path] = [base / x for x in watch_paths if (base / x).exists()]
+ self.suffix_filters: list[str] = suffix_filters or ["md"]
+ self.recursive: bool = recursive
+ self.force_polling: bool = force_polling
+ self.debounce: int = debounce
+ self.poll_delay_ms: int = poll_delay_ms
+ self.file_store = self.bind(file_store, BaseFileStore, default_factory=LocalFileStore)
+ self.file_parser = self.bind(file_parser, BaseFileParser, default_factory=DefaultFileParser)
+ self._stop_event: asyncio.Event = asyncio.Event()
+ self._background_task: asyncio.Task | None = None
+ self._retry_interval: float = 10
+
+ async def _start(self):
+ self._stop_event = asyncio.Event()
+ self._background_task = asyncio.create_task(self._background_run())
+ self.logger.info(f"Started watching: {[str(p) for p in self.watch_paths]}")
+
+ async def _background_run(self):
+ """Sync store then enter watch loop."""
+ await self.update_store()
+ await self.watch_loop()
+
+ async def _close(self):
+ self._stop_event.set()
+ if self._background_task:
+ await self._background_task
+ self.logger.info("Stopped watching")
+
+ def watch_filter(self, _change: Change, path: str) -> bool:
+ """Return True if the file suffix matches the filter list."""
+ if not self.suffix_filters:
+ return True
+ return any(path.endswith("." + s.strip(".")) for s in self.suffix_filters)
+
+ def _get_relative_path(self, path: str | Path) -> str:
+ """Return path relative to working_dir, or absolute path if outside."""
+ file_path = Path(path).absolute()
+ try:
+ return str(file_path.relative_to(self.working_path.absolute()))
+ except ValueError:
+ return str(file_path)
+
+ def _get_absolute_path(self, path: str | Path) -> Path:
+ """Return absolute path; relative paths are resolved against working_dir."""
+ p = Path(path)
+ return p if p.is_absolute() else self.working_path / p
+
+ async def scan_existing_files(self) -> dict[str, Path]:
+ """Collect watchable files under watch_paths as {relative_path: absolute_path}."""
+ files: dict[str, Path] = {}
+ for path in self.watch_paths:
+ if not path.exists():
+ continue
+ candidates = [path] if path.is_file() else (path.rglob("*") if self.recursive else path.iterdir())
+ for p in candidates:
+ if p.is_file() and self.watch_filter(Change.added, str(p)):
+ files[self._get_relative_path(p)] = p.absolute()
+ return files
+
+ @abstractmethod
+ async def watch_loop(self):
+ """Watch for file changes and dispatch events."""
+
+ @abstractmethod
+ async def update_store(self, dump: bool = True) -> dict[str, int]:
+ """Sync the store with watch_paths; dump store if any changes and dump=True.
+
+ Returns counts {"added": int, "modified": int, "deleted": int}.
+ """
+
+ @abstractmethod
+ async def on_added(self, path: str | list[str]):
+ """Handle file added event (relative paths)."""
+
+ @abstractmethod
+ async def on_modified(self, path: str | list[str]):
+ """Handle file modified event (relative paths)."""
+
+ @abstractmethod
+ async def on_deleted(self, path: str | list[str]):
+ """Handle file deleted event (relative paths)."""
diff --git a/reme4/components/file_watcher/lite_file_watcher.py b/reme4/components/file_watcher/lite_file_watcher.py
new file mode 100644
index 00000000..da48b2b3
--- /dev/null
+++ b/reme4/components/file_watcher/lite_file_watcher.py
@@ -0,0 +1,129 @@
+"""Polling-based file watcher using watchfiles."""
+
+import asyncio
+
+from watchfiles import Change, awatch
+
+from .base_file_watcher import BaseFileWatcher
+from ..component_registry import R
+from ...schema import FileChunk, FileNode
+
+
+@R.register("lite")
+class LiteFileWatcher(BaseFileWatcher):
+ """Polling-based file watcher using watchfiles awatch."""
+
+ async def _interruptible_sleep(self):
+ """Sleep until stop or timeout, whichever comes first."""
+ try:
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self._retry_interval)
+ except asyncio.TimeoutError:
+ pass
+
+ async def watch_loop(self):
+ if not self.watch_paths:
+ self.logger.warning("No watch paths specified")
+ return
+
+ while not self._stop_event.is_set():
+ valid_paths = [p for p in self.watch_paths if p.exists()]
+ if not valid_paths:
+ self.logger.warning(f"No valid paths, retrying in {self._retry_interval}s...")
+ await self._interruptible_sleep()
+ continue
+
+ invalid = set(self.watch_paths) - set(valid_paths)
+ if invalid:
+ self.logger.warning(f"Skipping invalid paths: {[str(p) for p in invalid]}")
+
+ try:
+ self.logger.info(f"Watching: {[str(p) for p in valid_paths]}")
+ async for changes in awatch(
+ *valid_paths,
+ watch_filter=self.watch_filter,
+ recursive=self.recursive,
+ force_polling=self.force_polling,
+ debounce=self.debounce,
+ poll_delay_ms=self.poll_delay_ms,
+ stop_event=self._stop_event,
+ ):
+ if self._stop_event.is_set():
+ break
+ await self._dispatch_changes(changes)
+ except Exception:
+ self.logger.exception(f"Watch error, retrying in {self._retry_interval}s...")
+ if not self._stop_event.is_set():
+ await self._interruptible_sleep()
+
+ async def _dispatch_changes(self, changes: set[tuple[Change, str]]):
+ """Classify raw changes and dispatch to event handlers."""
+ buckets: dict[Change, list[str]] = {Change.added: [], Change.modified: [], Change.deleted: []}
+ for c, p in changes:
+ if c in buckets:
+ buckets[c].append(self._get_relative_path(p))
+ for change, handler, label in (
+ (Change.added, self.on_added, "added"),
+ (Change.modified, self.on_modified, "modified"),
+ (Change.deleted, self.on_deleted, "deleted"),
+ ):
+ if buckets[change]:
+ self.logger.info(f"Detected {len(buckets[change])} {label} file(s)")
+ await handler(buckets[change])
+
+ async def update_store(self, dump: bool = True) -> dict[str, int]:
+ if self.file_store is None:
+ raise ValueError("file_store is not initialized!")
+
+ existing: dict[str, float] = {
+ rel: abs_p.stat().st_mtime for rel, abs_p in (await self.scan_existing_files()).items()
+ }
+ indexed: dict[str, float] = {n.path: n.st_mtime for n in await self.file_store.file_graph.get_nodes()}
+
+ to_delete = list(indexed.keys() - existing.keys())
+ to_add = list(existing.keys() - indexed.keys())
+ to_modify = [p for p in existing.keys() & indexed.keys() if existing[p] != indexed[p]]
+
+ if to_modify:
+ self.logger.info(f"Updating {len(to_modify)} modified file(s)")
+ await self.on_modified(to_modify)
+ if to_delete:
+ self.logger.info(f"Removing {len(to_delete)} deleted file(s)")
+ await self.on_deleted(to_delete)
+ if to_add:
+ self.logger.info(f"Indexing {len(to_add)} new file(s)")
+ await self.on_added(to_add)
+
+ changed = bool(to_add or to_modify or to_delete)
+ if not changed:
+ self.logger.info("Store is up to date")
+ if dump and changed:
+ await self.file_store.dump()
+ return {"added": len(to_add), "modified": len(to_modify), "deleted": len(to_delete)}
+
+ async def _parse_and_upsert(self, paths: list[str], action: str):
+ """Parse files and upsert into store. Shared by on_added / on_modified."""
+ if self.file_parser is None or self.file_store is None:
+ raise RuntimeError("file_parser or file_store is not initialized!")
+
+ parsed: list[tuple[FileNode, list[FileChunk]]] = []
+ for rel in paths:
+ abs_path = self._get_absolute_path(rel)
+ if abs_path.is_file():
+ self.logger.info(f"{action} file: {rel}")
+ parsed.append(await self.file_parser.parse(abs_path))
+ if parsed:
+ await self.file_store.delete_by_path([n.path for n, _ in parsed])
+ await self.file_store.upsert_file(parsed)
+
+ async def on_added(self, path: str | list[str]):
+ await self._parse_and_upsert([path] if isinstance(path, str) else path, "Adding")
+
+ async def on_modified(self, path: str | list[str]):
+ await self._parse_and_upsert([path] if isinstance(path, str) else path, "Updating")
+
+ async def on_deleted(self, path: str | list[str]):
+ if self.file_store is None:
+ raise RuntimeError("file_store is not initialized!")
+ paths = [path] if isinstance(path, str) else path
+ self.logger.info(f"Deleting {len(paths)} file(s)")
+ await self.file_store.delete_by_path(paths)
diff --git a/reme4/components/job/__init__.py b/reme4/components/job/__init__.py
new file mode 100644
index 00000000..0387572d
--- /dev/null
+++ b/reme4/components/job/__init__.py
@@ -0,0 +1,6 @@
+"""Job components for executing workflows."""
+
+from .base_job import BaseJob
+from .stream_job import StreamJob
+
+__all__ = ["BaseJob", "StreamJob"]
diff --git a/reme4/components/job/base_job.py b/reme4/components/job/base_job.py
new file mode 100644
index 00000000..e4681bf1
--- /dev/null
+++ b/reme4/components/job/base_job.py
@@ -0,0 +1,53 @@
+"""Base job component for sequential step execution."""
+
+from ..base_component import BaseComponent
+from ..component_registry import R
+from ..runtime_context import RuntimeContext
+from ...enumeration import ComponentEnum
+from ...schema import ComponentConfig, Response
+
+
+@R.register("base")
+class BaseJob(BaseComponent):
+ """Job that executes steps sequentially and returns a Response."""
+
+ component_type = ComponentEnum.JOB
+
+ def __init__(self, description: str, parameters: dict, steps: list[ComponentConfig | dict], **kwargs):
+ super().__init__(**kwargs)
+ self.description = description
+ self.parameters = parameters or {}
+ self.step_configs = steps or []
+
+ from ...steps import BaseStep
+
+ self.step_components: list[BaseStep] = []
+
+ async def _start(self) -> None:
+ """Resolve step configs into instantiated step components."""
+ assert self.app_context is not None, "app_context must be provided"
+ for raw in self.step_configs:
+ config = raw if isinstance(raw, ComponentConfig) else ComponentConfig(**raw)
+ if not config.backend:
+ raise ValueError("Step is missing the required 'backend' field")
+ step_cls = R.get(ComponentEnum.STEP, config.backend)
+ if not step_cls:
+ raise ValueError(f"Unregistered backend '{config.backend}' of type '{ComponentEnum.STEP}'")
+ params = config.model_dump()
+ params["app_context"] = self.app_context
+ self.step_components.append(step_cls(**params))
+
+ async def _close(self) -> None:
+ """Release all step components."""
+ self.step_components.clear()
+
+ async def __call__(self, **kwargs) -> Response:
+ """Execute all steps in order and return the final response."""
+ context = RuntimeContext(**kwargs)
+ try:
+ for step in self.step_components:
+ await step(context)
+ except Exception as e:
+ self.logger.exception(f"Failed to execute job: {e}")
+ context.response.answer = str(e)
+ return context.response
diff --git a/reme4/components/job/stream_job.py b/reme4/components/job/stream_job.py
new file mode 100644
index 00000000..89f1fab1
--- /dev/null
+++ b/reme4/components/job/stream_job.py
@@ -0,0 +1,21 @@
+"""Streaming job for real-time output delivery."""
+
+from .base_job import BaseJob
+from ..component_registry import R
+from ..runtime_context import RuntimeContext
+from ...enumeration import ChunkEnum
+
+
+@R.register("stream")
+class StreamJob(BaseJob):
+ """Job that streams chunks to a queue instead of returning a Response."""
+
+ async def __call__(self, **kwargs) -> None:
+ """Execute steps and stream output; errors are sent as ERROR chunks."""
+ context = RuntimeContext(**kwargs)
+ try:
+ for step in self.step_components:
+ await step(context)
+ except Exception as e:
+ await context.add_stream_string(str(e), ChunkEnum.ERROR)
+ await context.add_stream_done()
diff --git a/reme4/components/keyword_index/__init__.py b/reme4/components/keyword_index/__init__.py
new file mode 100644
index 00000000..f9969c6a
--- /dev/null
+++ b/reme4/components/keyword_index/__init__.py
@@ -0,0 +1,6 @@
+"""Keyword index components."""
+
+from .base_keyword_index import BaseKeywordIndex
+from .bm25_index import BM25Index
+
+__all__ = ["BaseKeywordIndex", "BM25Index"]
diff --git a/reme4/components/keyword_index/base_keyword_index.py b/reme4/components/keyword_index/base_keyword_index.py
new file mode 100644
index 00000000..0be21c58
--- /dev/null
+++ b/reme4/components/keyword_index/base_keyword_index.py
@@ -0,0 +1,70 @@
+"""Abstract base class for keyword index implementations."""
+
+from abc import abstractmethod
+from pathlib import Path
+
+from ..base_component import BaseComponent
+from ..tokenizer import BaseTokenizer
+from ...enumeration import ComponentEnum
+
+
+class BaseKeywordIndex(BaseComponent):
+ """Abstract base class for keyword index implementations."""
+
+ component_type = ComponentEnum.KEYWORD_INDEX
+
+ def __init__(self, tokenizer: str = "default", index_version: str = "v1", **kwargs):
+ super().__init__(**kwargs)
+ from ..tokenizer import RegexTokenizer
+
+ self.tokenizer = self.bind(tokenizer, BaseTokenizer, default_factory=RegexTokenizer)
+ self.index_version = index_version
+ self.index_path = self.working_metadata_path / self.component_type.value
+ self.index_path.mkdir(parents=True, exist_ok=True)
+
+ async def _start(self) -> None:
+ """Load existing index from disk if available."""
+ await self.load()
+
+ async def _close(self) -> None:
+ """Save index to disk on shutdown."""
+ await self.dump()
+
+ @property
+ def index_file(self) -> Path:
+ """Return the pickle file path derived from tokenizer name."""
+ if self.tokenizer is None:
+ raise RuntimeError("Tokenizer not initialized. Call start() first.")
+ name = type(self.tokenizer).__name__.replace("Tokenizer", "").lower()
+ return self.index_path / f"bm25_{name}_{self.index_version}.pkl"
+
+ def _tokenize(self, text: str) -> list[str]:
+ """Tokenize a text string into tokens."""
+ if self.tokenizer is None:
+ raise RuntimeError("Tokenizer not initialized. Call start() first.")
+ return self.tokenizer.tokenize([text])[0]
+
+ @abstractmethod
+ async def add_docs(self, docs_dict: dict[str, str]) -> None:
+ """Index or update documents. Mapping of doc_id to content."""
+
+ @abstractmethod
+ async def delete_docs(self, doc_ids: list[str]) -> None:
+ """Remove documents by their IDs."""
+
+ @abstractmethod
+ async def retrieve(self, query: str, limit: int = 3) -> dict[str, float]:
+ """Search documents. Returns {doc_id: score} sorted descending."""
+
+ @abstractmethod
+ async def clear(self) -> None:
+ """Reset index to empty state."""
+
+ async def reset_index(self, docs_dict: dict[str, str]) -> None:
+ """Clear index, re-add all documents, and persist."""
+ await self.clear()
+ await self.add_docs(docs_dict)
+ await self.dump()
+
+ async def optimize_index(self) -> None:
+ """Optimize index for performance. Override in subclass if needed."""
diff --git a/reme4/components/keyword_index/bm25_index.py b/reme4/components/keyword_index/bm25_index.py
new file mode 100644
index 00000000..81899828
--- /dev/null
+++ b/reme4/components/keyword_index/bm25_index.py
@@ -0,0 +1,206 @@
+"""BM25 search engine with persistent index support.
+
+Implements Okapi BM25 ranking with an inverted index for efficient
+document lookup, incremental updates, and pickle-based persistence.
+"""
+
+import math
+import pickle
+from collections import Counter
+from typing import TypedDict
+
+from .base_keyword_index import BaseKeywordIndex
+from ..component_registry import R
+
+
+class DocMeta(TypedDict):
+ """Per-document metadata: token count and unique token ID set."""
+
+ len: int
+ token_ids: set[int]
+
+
+@R.register("bm25")
+class BM25Index(BaseKeywordIndex):
+ """BM25 search engine with file-based persistence.
+
+ Args:
+ k1: Term frequency saturation parameter (default 1.5).
+ b: Document length normalization parameter (default 0.75).
+ """
+
+ def __init__(self, k1: float = 1.5, b: float = 0.75, **kwargs):
+ super().__init__(**kwargs)
+ self.k1 = k1
+ self.b = b
+ self.vocab: dict[str, int] = {} # token -> token_id
+ self.inverted_index: dict[int, dict[str, int]] = {} # token_id -> {doc_id: tf}
+ self.doc_meta: dict[str, DocMeta] = {} # doc_id -> metadata
+ self.total_len: int = 0
+ self._idf_cache: dict[int, float] = {}
+
+ # -- Properties -----------------------------------------------------------
+
+ @property
+ def n_docs(self) -> int:
+ """Number of indexed documents."""
+ return len(self.doc_meta)
+
+ @property
+ def avg_len(self) -> float:
+ """Average document length in tokens."""
+ return self.total_len / self.n_docs if self.n_docs > 0 else 0.0
+
+ # -- Internal helpers -----------------------------------------------------
+
+ def _tokens_to_ids(self, tokens: list[str]) -> list[int]:
+ """Map tokens to integer IDs, assigning new IDs on first encounter."""
+ ids = []
+ for token in tokens:
+ token = token.strip()
+ if token:
+ ids.append(self.vocab.setdefault(token, len(self.vocab)))
+ return ids
+
+ def _remove_doc(self, doc_id: str) -> None:
+ """Remove a single document from all internal structures."""
+ if doc_id not in self.doc_meta:
+ return
+ meta = self.doc_meta[doc_id]
+ self.total_len -= meta["len"]
+ for tid in meta["token_ids"]:
+ if tid in self.inverted_index:
+ self.inverted_index[tid].pop(doc_id, None)
+ if not self.inverted_index[tid]:
+ del self.inverted_index[tid]
+ del self.doc_meta[doc_id]
+
+ def _get_idf(self, token_id: int) -> float:
+ """Compute and cache IDF for a token ID."""
+ if token_id in self._idf_cache:
+ return self._idf_cache[token_id]
+ df = len(self.inverted_index.get(token_id, {}))
+ self._idf_cache[token_id] = math.log(1 + (self.n_docs - df + 0.5) / (df + 0.5)) if df else 0.0
+ return self._idf_cache[token_id]
+
+ # -- Public API -----------------------------------------------------------
+
+ async def add_docs(self, docs_dict: dict[str, str]) -> None:
+ """Index or update multiple documents. Mapping of doc_id to content."""
+ for doc_id, content in docs_dict.items():
+ if doc_id in self.doc_meta:
+ self._remove_doc(doc_id)
+ tokens = self._tokenize(content)
+ if not tokens:
+ continue
+ token_ids = self._tokens_to_ids(tokens)
+ token_counts = Counter(token_ids)
+ for tid, tf in token_counts.items():
+ self.inverted_index.setdefault(tid, {})[doc_id] = tf
+ self.doc_meta[doc_id] = {"len": len(token_ids), "token_ids": set(token_counts)}
+ self.total_len += len(token_ids)
+ self._idf_cache = {}
+
+ async def delete_docs(self, doc_ids: list[str]) -> None:
+ """Remove documents by their IDs."""
+ for doc_id in doc_ids:
+ self._remove_doc(doc_id)
+ self._idf_cache = {}
+
+ async def retrieve(self, query: str, limit: int = 3) -> dict[str, float]:
+ """Search documents. Returns {doc_id: score} sorted descending."""
+ query_ids = [self.vocab[t] for t in self._tokenize(query) if t in self.vocab]
+ if not query_ids or self.n_docs == 0:
+ return {}
+
+ scores: dict[str, float] = {}
+ avg_len = self.avg_len
+ for tid in query_ids:
+ if tid not in self.inverted_index:
+ continue
+ idf = self._get_idf(tid)
+ for doc_id, tf in self.inverted_index[tid].items():
+ doc_len = self.doc_meta[doc_id]["len"]
+ tf_score = tf * (self.k1 + 1) / (tf + self.k1 * (1 - self.b + self.b * doc_len / avg_len))
+ scores[doc_id] = scores.get(doc_id, 0.0) + idf * tf_score
+
+ return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True)[:limit]) if scores else {}
+
+ async def dump(self) -> None:
+ """Persist index to disk via pickle (atomic rename)."""
+ try:
+ tmp = self.index_file.with_suffix(".tmp")
+ with open(tmp, "wb") as f:
+ pickle.dump(
+ {
+ "vocab": self.vocab,
+ "inverted_index": self.inverted_index,
+ "doc_meta": self.doc_meta,
+ "total_len": self.total_len,
+ "k1": self.k1,
+ "b": self.b,
+ },
+ f,
+ )
+ tmp.replace(self.index_file)
+ self.logger.info(f"Saved {self.n_docs} docs to {self.index_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to write {self.index_file}: {e}")
+
+ async def load(self) -> None:
+ """Load index from disk. No-op if file missing; clears index on corruption."""
+ if not self.index_file.exists():
+ return
+ try:
+ with open(self.index_file, "rb") as f:
+ data = pickle.load(f)
+ self.vocab = data["vocab"]
+ self.inverted_index = data["inverted_index"]
+ self.doc_meta = data["doc_meta"]
+ self.total_len = data.get("total_len", 0)
+ self.k1 = data.get("k1", 1.5)
+ self.b = data.get("b", 0.75)
+ self._idf_cache = {}
+ self.logger.info(f"Loaded {self.n_docs} docs from {self.index_file}")
+ except Exception as e:
+ self.logger.exception(f"Failed to load index: {e}")
+ self.index_file.unlink(missing_ok=True)
+ await self.clear()
+
+ async def clear(self) -> None:
+ """Reset index to empty state and remove persisted file."""
+ self.vocab = {}
+ self.inverted_index = {}
+ self.doc_meta = {}
+ self.total_len = 0
+ self._idf_cache = {}
+ self.index_file.unlink(missing_ok=True)
+
+ async def optimize_index(self) -> None:
+ """Rebuild vocab to remove unused tokens and compact token IDs."""
+ used_token_ids: set[int] = set()
+ for tid in self.inverted_index:
+ used_token_ids.add(tid)
+ if not used_token_ids:
+ await self.clear()
+ return
+
+ # Build compact ID mapping
+ old_to_new: dict[int, int] = {}
+ new_vocab: dict[str, int] = {}
+ for token, old_tid in self.vocab.items():
+ if old_tid in used_token_ids:
+ new_tid = len(new_vocab)
+ new_vocab[token] = new_tid
+ old_to_new[old_tid] = new_tid
+
+ # Rebuild inverted index and doc_meta with new IDs
+ new_inverted_index: dict[int, dict[str, int]] = {}
+ for old_tid, postings in self.inverted_index.items():
+ new_inverted_index[old_to_new[old_tid]] = postings
+ for meta in self.doc_meta.values():
+ meta["token_ids"] = {old_to_new[t] for t in meta["token_ids"] if t in old_to_new}
+
+ self.vocab = new_vocab
+ self.inverted_index = new_inverted_index
+ self._idf_cache = {}
diff --git a/reme4/components/prompt_handler.py b/reme4/components/prompt_handler.py
new file mode 100644
index 00000000..a9ae203a
--- /dev/null
+++ b/reme4/components/prompt_handler.py
@@ -0,0 +1,125 @@
+"""Prompt template loader and formatter with conditional-line and i18n support."""
+
+import inspect
+import json
+import re
+from pathlib import Path
+from string import Formatter
+
+import yaml
+
+# Matches a leading flag tag like "[verbose] some text".
+_FLAG_PATTERN = re.compile(r"^\[(\w+)]")
+
+
+class PromptHandler:
+ """Loads prompts from YAML/JSON or class-adjacent files and formats them.
+
+ Templates may carry a language suffix (``key_en``, ``key_zh``); ``get_prompt``
+ falls back to the bare key when no localized variant exists. ``prompt_format``
+ additionally supports per-line flags such as ``[verbose] extra text`` that
+ are kept only when the matching flag kwarg is truthy.
+ """
+
+ _SUPPORTED_EXTENSIONS = {".yaml", ".yml", ".json"}
+
+ def __init__(self, language: str = "", **kwargs):
+ # Only string entries are treated as prompts; other kwargs are ignored.
+ self.data: dict[str, str] = {k: v for k, v in kwargs.items() if isinstance(v, str)}
+ self.language: str = language.strip()
+
+ def load_prompt_by_file(
+ self,
+ prompt_file_path: str | Path | None = None,
+ overwrite: bool = True,
+ ) -> "PromptHandler":
+ """Load prompts from a YAML or JSON file; silently skip on any error."""
+ if prompt_file_path is None:
+ return self
+
+ path = Path(prompt_file_path)
+ if not path.exists() or path.suffix.lower() not in self._SUPPORTED_EXTENSIONS:
+ return self
+
+ try:
+ with path.open(encoding="utf-8") as f:
+ prompt_dict = yaml.safe_load(f) if path.suffix.lower() in (".yaml", ".yml") else json.load(f)
+ except (json.JSONDecodeError, yaml.YAMLError, OSError):
+ return self
+
+ return self.load_prompt_dict(prompt_dict, overwrite)
+
+ def load_prompt_by_class(self, cls: type, overwrite: bool = True) -> "PromptHandler":
+ """Load prompts from ``.yaml`` (or ``.yml``) next to `cls`."""
+ try:
+ base_path = Path(inspect.getfile(cls)).with_suffix("")
+ except (TypeError, OSError):
+ return self
+
+ for ext in (".yaml", ".yml"):
+ if (prompt_path := base_path.with_suffix(ext)).exists():
+ return self.load_prompt_by_file(prompt_path, overwrite)
+
+ return self
+
+ def load_prompt_dict(self, prompt_dict: dict | None = None, overwrite: bool = True) -> "PromptHandler":
+ """Merge string entries from `prompt_dict` into the in-memory store."""
+ if not isinstance(prompt_dict, dict):
+ return self
+
+ for key, value in prompt_dict.items():
+ if isinstance(value, str) and (overwrite or key not in self.data):
+ self.data[key] = value
+
+ return self
+
+ def get_prompt(self, prompt_name: str) -> str:
+ """Return the template, preferring the language-suffixed variant when set."""
+ for key in (f"{prompt_name}_{self.language}", prompt_name) if self.language else (prompt_name,):
+ if key in self.data:
+ return self.data[key].strip()
+
+ raise KeyError(f"Prompt '{prompt_name}' not found. Available: {list(self.data.keys())[:10]}")
+
+ def has_prompt(self, prompt_name: str) -> bool:
+ """True if either the localized or bare prompt is registered."""
+ keys = (f"{prompt_name}_{self.language}", prompt_name) if self.language else (prompt_name,)
+ return any(k in self.data for k in keys)
+
+ def list_prompts(self, language_filter: str | None = None) -> list[str]:
+ """List all keys, optionally filtered to those ending with ``_``."""
+ if language_filter is None:
+ return list(self.data.keys())
+ suffix = f"_{language_filter.strip()}"
+ return [k for k in self.data if k.endswith(suffix)]
+
+ def prompt_format(self, prompt_name: str, validate: bool = True, **kwargs) -> str:
+ """Render a prompt: strip inactive flag-lines, then ``str.format`` it.
+
+ Boolean kwargs are treated as flags controlling ``[flag]`` line filtering.
+ Remaining kwargs become positional substitutions for ``{var}`` placeholders.
+ With `validate=True`, missing substitutions raise ``ValueError``.
+ """
+ prompt = self.get_prompt(prompt_name)
+ flags = {k: v for k, v in kwargs.items() if isinstance(v, bool)}
+ formats = {k: v for k, v in kwargs.items() if not isinstance(v, bool)}
+
+ # Keep lines without flags; otherwise keep when at least one flag is enabled.
+ if flags:
+ lines = []
+ for line in prompt.split("\n"):
+ active_flags = _FLAG_PATTERN.findall(line)
+ cleaned = _FLAG_PATTERN.sub("", line).lstrip()
+ if not active_flags or any(flags.get(f, False) for f in active_flags):
+ lines.append(cleaned)
+ prompt = "\n".join(lines)
+
+ if validate:
+ required = {f for _, f, _, _ in Formatter().parse(prompt) if f is not None}
+ if missing := required - set(formats.keys()):
+ raise ValueError(f"Missing format variables for '{prompt_name}': {sorted(missing)}")
+
+ return prompt.format(**formats).strip() if formats else prompt
+
+ def __repr__(self) -> str:
+ return f"PromptHandler(language='{self.language}', num_prompts={len(self.data)})"
diff --git a/reme4/components/runtime_context.py b/reme4/components/runtime_context.py
new file mode 100644
index 00000000..76ebaf16
--- /dev/null
+++ b/reme4/components/runtime_context.py
@@ -0,0 +1,87 @@
+"""Per-request runtime context shared across steps and jobs."""
+
+import asyncio
+
+from ..enumeration import ChunkEnum
+from ..schema import Response, StreamChunk
+
+
+class RuntimeContext:
+ """Scratch space for a single execution.
+
+ Holds the response object, an optional stream queue, and a free-form
+ data dict accessed via mapping-style operators.
+ """
+
+ def __init__(
+ self,
+ response: Response | None = None,
+ stream_queue: asyncio.Queue | None = None,
+ **kwargs,
+ ):
+ self.response: Response = response or Response()
+ self.stream_queue: asyncio.Queue | None = stream_queue
+ self.data: dict = kwargs
+
+ def get(self, key: str, default=None):
+ """Get a value from the data dict."""
+ return self.data.get(key, default)
+
+ def update(self, data: dict) -> "RuntimeContext":
+ """Merge data into the context."""
+ self.data.update(data)
+ return self
+
+ def __getitem__(self, key: str):
+ return self.data[key]
+
+ def __setitem__(self, key: str, value):
+ self.data[key] = value
+
+ def __delitem__(self, key: str):
+ del self.data[key]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self.data
+
+ @property
+ def stream(self) -> bool:
+ """Whether streaming is enabled."""
+ return self.stream_queue is not None
+
+ @classmethod
+ def from_context(cls, context: "RuntimeContext | None" = None, **kwargs) -> "RuntimeContext":
+ """Reuse or create a RuntimeContext."""
+ # Reuse the existing context (merging kwargs) or create a new one.
+ if context is None:
+ return cls(**kwargs)
+ context.update(kwargs)
+ return context
+
+ async def _enqueue(self, chunk: StreamChunk) -> None:
+ """Put a chunk on the stream queue."""
+ if self.stream_queue is None:
+ raise RuntimeError("Stream queue not initialized")
+ await self.stream_queue.put(chunk)
+
+ async def add_stream_string(self, chunk: str, chunk_type: ChunkEnum) -> "RuntimeContext":
+ """Emit a text chunk to the stream queue."""
+ # Emit a text chunk to the stream queue.
+ await self._enqueue(StreamChunk(chunk_type=chunk_type, chunk=chunk))
+ return self
+
+ async def add_stream_done(self) -> "RuntimeContext":
+ """Emit the terminal DONE marker to close the stream."""
+ # Emit the terminal DONE marker to close the stream.
+ await self._enqueue(StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True))
+ return self
+
+ def apply_mapping(self, mapping: dict[str, str]) -> "RuntimeContext":
+ """Copy data[source] into data[target] for each mapping pair."""
+ # Copy data[source] into data[target] for each {source: target} pair.
+ if not mapping:
+ return self
+ for source, target in mapping.items():
+ if source in self.data:
+ self.data[target] = self.data[source]
+ return self
diff --git a/reme4/components/service/__init__.py b/reme4/components/service/__init__.py
new file mode 100644
index 00000000..41d8f7b0
--- /dev/null
+++ b/reme4/components/service/__init__.py
@@ -0,0 +1,11 @@
+"""Service components for exposing jobs via different protocols."""
+
+from .base_service import BaseService
+from .http_service import HttpService
+from .mcp_service import MCPService
+
+__all__ = [
+ "BaseService",
+ "HttpService",
+ "MCPService",
+]
diff --git a/reme4/components/service/base_service.py b/reme4/components/service/base_service.py
new file mode 100644
index 00000000..8fceeadb
--- /dev/null
+++ b/reme4/components/service/base_service.py
@@ -0,0 +1,48 @@
+"""Base service class for exposing jobs via HTTP, MCP, etc."""
+
+from abc import abstractmethod
+from typing import TYPE_CHECKING
+
+from ..base_component import BaseComponent
+from ..job.base_job import BaseJob
+from ...enumeration import ComponentEnum
+
+if TYPE_CHECKING:
+ from ...application import Application
+
+
+class BaseService(BaseComponent):
+ """Base class for services that expose jobs via HTTP, MCP, etc."""
+
+ component_type = ComponentEnum.SERVICE
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.service = None
+
+ @abstractmethod
+ def build_service(self, app: "Application") -> None:
+ """Initialize the underlying service framework."""
+
+ @abstractmethod
+ def add_job(self, job: BaseJob) -> None:
+ """Register a single job with the service."""
+
+ @abstractmethod
+ def start_service(self, app: "Application") -> None:
+ """Start serving requests."""
+
+ def add_jobs(self, app: "Application") -> None:
+ """Register all jobs from the application context."""
+ for name, job in app.context.jobs.items():
+ try:
+ self.add_job(job)
+ self.logger.info(f"Added job: {name}")
+ except Exception as e:
+ self.logger.error(f"Failed to add job {name}: {e}")
+
+ def run_app(self, app: "Application") -> None:
+ """Build, populate, and start the service."""
+ self.build_service(app)
+ self.add_jobs(app)
+ self.start_service(app)
diff --git a/reme4/components/service/http_service.py b/reme4/components/service/http_service.py
new file mode 100644
index 00000000..a1092947
--- /dev/null
+++ b/reme4/components/service/http_service.py
@@ -0,0 +1,99 @@
+"""HTTP service implementation for ReMe."""
+
+import asyncio
+import json
+import os
+import warnings
+from collections.abc import AsyncGenerator
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING
+
+import uvicorn
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+
+from .base_service import BaseService
+from ..component_registry import R
+from ..job import BaseJob, StreamJob
+from ...constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT, REME_SERVICE_INFO
+from ...schema import Request, Response
+from ...utils import execute_stream_task
+
+if TYPE_CHECKING:
+ from ...application import Application
+
+
+@R.register("http")
+class HttpService(BaseService):
+ """HTTP service: normal jobs -> JSON endpoints, stream jobs -> SSE endpoints."""
+
+ def __init__(self, host: str = REME_DEFAULT_HOST, port: int = REME_DEFAULT_PORT, **kwargs):
+ super().__init__(**kwargs)
+ self.host: str = host
+ self.port: int = port
+
+ def _add_job(self, job: BaseJob) -> None:
+ async def execute_endpoint(request: Request) -> Response:
+ return await job(**request.model_dump(exclude_none=True))
+
+ self.service.post(path=f"/{job.name}", response_model=Response, description=job.description)(execute_endpoint)
+
+ def _add_stream_job(self, job: StreamJob) -> None:
+ async def execute_stream_endpoint(request: Request) -> StreamingResponse:
+ stream_queue = asyncio.Queue()
+ task = asyncio.create_task(job(stream_queue=stream_queue, **request.model_dump(exclude_none=True)))
+
+ async def generate_stream() -> AsyncGenerator[bytes, None]:
+ async for chunk in execute_stream_task(
+ stream_queue=stream_queue,
+ task=task,
+ task_name=job.name,
+ output_format="bytes",
+ ):
+ assert isinstance(chunk, bytes)
+ yield chunk
+
+ return StreamingResponse(generate_stream(), media_type="text/event-stream")
+
+ self.service.post(f"/{job.name}")(execute_stream_endpoint)
+
+ def add_job(self, job: BaseJob) -> None:
+ if isinstance(job, StreamJob):
+ self._add_stream_job(job)
+ else:
+ self._add_job(job)
+
+ def build_service(self, app: "Application") -> None:
+ @asynccontextmanager
+ async def lifespan(_: FastAPI):
+ await app.start()
+ service_info = json.dumps({"host": self.host, "port": self.port})
+ os.environ[REME_SERVICE_INFO] = service_info
+ self.logger.info(f"ReMe Service started: {REME_SERVICE_INFO}={service_info}")
+ yield
+ await app.close()
+
+ self.service = FastAPI(title=app.config.app_name, lifespan=lifespan)
+ self.service.add_middleware(
+ CORSMiddleware, # type: ignore[arg-type]
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ def start_service(self, app: "Application") -> None:
+ # uvicorn 0.41 still imports websockets.legacy / WebSocketServerProtocol
+ # on startup; silence those specific lines since we don't use WebSocket.
+ warnings.filterwarnings(
+ "ignore",
+ category=DeprecationWarning,
+ message=r".*websockets\.legacy is deprecated.*",
+ )
+ warnings.filterwarnings(
+ "ignore",
+ category=DeprecationWarning,
+ message=r".*WebSocketServerProtocol is deprecated.*",
+ )
+ uvicorn.run(self.service, host=self.host, port=self.port, **self.kwargs)
diff --git a/reme4/components/service/mcp_service.py b/reme4/components/service/mcp_service.py
new file mode 100644
index 00000000..8f22450d
--- /dev/null
+++ b/reme4/components/service/mcp_service.py
@@ -0,0 +1,71 @@
+"""MCP (Model Context Protocol) service implementation."""
+
+import json
+import os
+from contextlib import asynccontextmanager
+from typing import TYPE_CHECKING
+
+from fastmcp import FastMCP
+from fastmcp.server.server import Transport
+from fastmcp.tools import FunctionTool
+
+from .base_service import BaseService
+from ..component_registry import R
+from ..job import StreamJob, BaseJob
+from ...constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT, REME_SERVICE_INFO
+
+if TYPE_CHECKING:
+ from ...application import Application
+
+
+@R.register("mcp")
+class MCPService(BaseService):
+ """Expose jobs as MCP (Model Context Protocol) tools."""
+
+ def __init__(
+ self,
+ transport: Transport = "sse",
+ host: str = REME_DEFAULT_HOST,
+ port: int = REME_DEFAULT_PORT,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.transport: Transport = transport
+ self.host: str = host
+ self.port: int = port
+
+ def build_service(self, app: "Application") -> None:
+ @asynccontextmanager
+ async def lifespan(_: FastMCP):
+ await app.start()
+ service_info = json.dumps({"host": self.host, "port": self.port})
+ os.environ[REME_SERVICE_INFO] = service_info
+ self.logger.info(f"ReMe MCP Service started: {REME_SERVICE_INFO}={service_info}")
+ yield
+ await app.close()
+
+ self.service = FastMCP(name=app.config.app_name, lifespan=lifespan)
+
+ def add_job(self, job: "BaseJob") -> None:
+ if isinstance(job, StreamJob):
+ return
+
+ async def execute_tool(**kwargs):
+ response = await job(**kwargs)
+ return response.answer
+
+ self.service.add_tool(
+ FunctionTool(
+ name=job.name,
+ description=job.description,
+ fn=execute_tool,
+ parameters=job.parameters or None,
+ ),
+ )
+
+ def start_service(self, app: "Application") -> None:
+ transport_kwargs = {}
+ if self.transport != "stdio":
+ transport_kwargs["host"] = self.host
+ transport_kwargs["port"] = self.port
+ self.service.run(transport=self.transport, show_banner=False, **transport_kwargs)
diff --git a/reme4/components/tokenizer/__init__.py b/reme4/components/tokenizer/__init__.py
new file mode 100644
index 00000000..ff364470
--- /dev/null
+++ b/reme4/components/tokenizer/__init__.py
@@ -0,0 +1,11 @@
+"""Tokenizer component module."""
+
+from .base_tokenizer import BaseTokenizer
+from .jieba_tokenizer import JiebaTokenizer
+from .regex_tokenizer import RegexTokenizer
+
+__all__ = [
+ "BaseTokenizer",
+ "JiebaTokenizer",
+ "RegexTokenizer",
+]
diff --git a/reme4/components/tokenizer/base_tokenizer.py b/reme4/components/tokenizer/base_tokenizer.py
new file mode 100644
index 00000000..713c2ecb
--- /dev/null
+++ b/reme4/components/tokenizer/base_tokenizer.py
@@ -0,0 +1,44 @@
+"""Abstract base class for tokenizers."""
+
+from abc import abstractmethod
+from pathlib import Path
+
+import aiofiles
+
+from ..base_component import BaseComponent
+from ...enumeration import ComponentEnum
+
+
+class BaseTokenizer(BaseComponent):
+ """Base tokenizer. Subclasses must implement `tokenize`. Loads stopwords on start."""
+
+ component_type = ComponentEnum.TOKENIZER
+ DEFAULT_STOPWORDS_PATH = Path(__file__).parent / "stopwords"
+
+ def __init__(self, stopwords_path: str | Path | None = None, **kwargs):
+ super().__init__(**kwargs)
+ self.stopwords_path = Path(stopwords_path) if stopwords_path else self.DEFAULT_STOPWORDS_PATH
+ self._stopwords: set[str] = set()
+
+ async def _start(self) -> None:
+ """Load stopwords from file."""
+ if not self.stopwords_path.exists():
+ self.logger.warning(f"Stopwords file not found: {self.stopwords_path}")
+ return
+ async with aiofiles.open(self.stopwords_path, encoding="utf-8") as f:
+ content = await f.read()
+ self._stopwords = {line.strip().lower() for line in content.splitlines() if line.strip()}
+ self.logger.info(f"Loaded {len(self._stopwords)} stopwords from {self.stopwords_path}")
+
+ async def _close(self) -> None:
+ """Clear stopwords."""
+ self._stopwords.clear()
+
+ @property
+ def stopwords(self) -> set[str]:
+ """Get the loaded stopwords."""
+ return self._stopwords
+
+ @abstractmethod
+ def tokenize(self, texts: list[str], **kwargs) -> list[list[str]]:
+ """Tokenize a list of texts."""
diff --git a/reme4/components/tokenizer/jieba_tokenizer.py b/reme4/components/tokenizer/jieba_tokenizer.py
new file mode 100644
index 00000000..4391c89c
--- /dev/null
+++ b/reme4/components/tokenizer/jieba_tokenizer.py
@@ -0,0 +1,27 @@
+"""Jieba tokenizer for Chinese text segmentation."""
+
+from .base_tokenizer import BaseTokenizer
+from ..component_registry import R
+
+
+@R.register("jieba")
+class JiebaTokenizer(BaseTokenizer):
+ """Tokenizer using jieba for Chinese text segmentation."""
+
+ def __init__(self, filter_stopwords: bool = True, **kwargs):
+ super().__init__(**kwargs)
+ self.filter_stopwords = filter_stopwords
+
+ def tokenize(self, texts: list[str], lower: bool = True, **kwargs) -> list[list[str]]:
+ """Tokenize texts using jieba."""
+ import jieba
+
+ result = []
+ for text in texts:
+ tokens = jieba.cut(text)
+ if lower:
+ tokens = [x.lower() for x in tokens]
+ if self.filter_stopwords and self._stopwords:
+ tokens = [t for t in tokens if t not in self._stopwords]
+ result.append(tokens)
+ return result
diff --git a/reme4/components/tokenizer/regex_tokenizer.py b/reme4/components/tokenizer/regex_tokenizer.py
new file mode 100644
index 00000000..4379a60e
--- /dev/null
+++ b/reme4/components/tokenizer/regex_tokenizer.py
@@ -0,0 +1,31 @@
+"""Regex tokenizer with Chinese character splitting."""
+
+import re
+from .base_tokenizer import BaseTokenizer
+from ..component_registry import R
+
+
+@R.register("regex")
+class RegexTokenizer(BaseTokenizer):
+ """Tokenizer using regex: splits Chinese chars individually, extracts non-Chinese words."""
+
+ WORD_PATTERN = re.compile(r"(?u)\b\w\w+\b") # 2+ char words
+ CHINESE_PATTERN = re.compile(r"[一-鿿]") # single Chinese char
+
+ def __init__(self, filter_stopwords: bool = True, **kwargs):
+ super().__init__(**kwargs)
+ self.filter_stopwords = filter_stopwords
+
+ def tokenize(self, texts: list[str], lower: bool = True, **kwargs) -> list[list[str]]:
+ """Tokenize texts. Extracts Chinese chars, then non-Chinese words from remaining text."""
+ result = []
+ for text in texts:
+ # Extract Chinese chars individually, then non-Chinese words
+ tokens = self.CHINESE_PATTERN.findall(text)
+ tokens.extend(self.WORD_PATTERN.findall(self.CHINESE_PATTERN.sub(" ", text)))
+ if lower:
+ tokens = [t.lower() for t in tokens]
+ if self.filter_stopwords and self._stopwords:
+ tokens = [t for t in tokens if t not in self._stopwords]
+ result.append(tokens)
+ return result
diff --git a/reme4/components/tokenizer/stopwords b/reme4/components/tokenizer/stopwords
new file mode 100644
index 00000000..fc3c61d0
--- /dev/null
+++ b/reme4/components/tokenizer/stopwords
@@ -0,0 +1,1395 @@
+--
+?
+“
+”
+》
+--
+able
+about
+above
+according
+accordingly
+across
+actually
+after
+afterwards
+again
+against
+ain't
+all
+allow
+allows
+almost
+alone
+along
+already
+also
+although
+always
+am
+among
+amongst
+an
+and
+another
+any
+anybody
+anyhow
+anyone
+anything
+anyway
+anyways
+anywhere
+apart
+appear
+appreciate
+appropriate
+are
+aren't
+around
+as
+a's
+aside
+ask
+asking
+associated
+at
+available
+away
+awfully
+be
+became
+because
+become
+becomes
+becoming
+been
+before
+beforehand
+behind
+being
+believe
+below
+beside
+besides
+best
+better
+between
+beyond
+both
+brief
+but
+by
+came
+can
+cannot
+cant
+can't
+cause
+causes
+certain
+certainly
+changes
+clearly
+c'mon
+co
+com
+come
+comes
+concerning
+consequently
+consider
+considering
+contain
+containing
+contains
+corresponding
+could
+couldn't
+course
+c's
+currently
+definitely
+described
+despite
+did
+didn't
+different
+do
+does
+doesn't
+doing
+done
+don't
+down
+downwards
+during
+each
+edu
+eg
+eight
+either
+else
+elsewhere
+enough
+entirely
+especially
+et
+etc
+even
+ever
+every
+everybody
+everyone
+everything
+everywhere
+ex
+exactly
+example
+except
+far
+few
+fifth
+first
+five
+followed
+following
+follows
+for
+former
+formerly
+forth
+four
+from
+further
+furthermore
+get
+gets
+getting
+given
+gives
+go
+goes
+going
+gone
+got
+gotten
+greetings
+had
+hadn't
+happens
+hardly
+has
+hasn't
+have
+haven't
+having
+he
+hello
+help
+hence
+her
+here
+hereafter
+hereby
+herein
+here's
+hereupon
+hers
+herself
+he's
+hi
+him
+himself
+his
+hither
+hopefully
+how
+howbeit
+however
+i'd
+ie
+if
+ignored
+i'll
+i'm
+immediate
+in
+inasmuch
+inc
+indeed
+indicate
+indicated
+indicates
+inner
+insofar
+instead
+into
+inward
+is
+isn't
+it
+it'd
+it'll
+its
+it's
+itself
+i've
+just
+keep
+keeps
+kept
+know
+known
+knows
+last
+lately
+later
+latter
+latterly
+least
+less
+lest
+let
+let's
+like
+liked
+likely
+little
+look
+looking
+looks
+ltd
+mainly
+many
+may
+maybe
+me
+mean
+meanwhile
+merely
+might
+more
+moreover
+most
+mostly
+much
+must
+my
+myself
+name
+namely
+nd
+near
+nearly
+necessary
+need
+needs
+neither
+never
+nevertheless
+new
+next
+nine
+no
+nobody
+non
+none
+noone
+nor
+normally
+not
+nothing
+novel
+now
+nowhere
+obviously
+of
+off
+often
+oh
+ok
+okay
+old
+on
+once
+one
+ones
+only
+onto
+or
+other
+others
+otherwise
+ought
+our
+ours
+ourselves
+out
+outside
+over
+overall
+own
+particular
+particularly
+per
+perhaps
+placed
+please
+plus
+possible
+presumably
+probably
+provides
+que
+quite
+qv
+rather
+rd
+re
+really
+reasonably
+regarding
+regardless
+regards
+relatively
+respectively
+right
+said
+same
+saw
+say
+saying
+says
+second
+secondly
+see
+seeing
+seem
+seemed
+seeming
+seems
+seen
+self
+selves
+sensible
+sent
+serious
+seriously
+seven
+several
+shall
+she
+should
+shouldn't
+since
+six
+so
+some
+somebody
+somehow
+someone
+something
+sometime
+sometimes
+somewhat
+somewhere
+soon
+sorry
+specified
+specify
+specifying
+still
+sub
+such
+sup
+sure
+take
+taken
+tell
+tends
+th
+than
+thank
+thanks
+thanx
+that
+thats
+that's
+the
+their
+theirs
+them
+themselves
+then
+thence
+there
+thereafter
+thereby
+therefore
+therein
+theres
+there's
+thereupon
+these
+they
+they'd
+they'll
+they're
+they've
+think
+third
+this
+thorough
+thoroughly
+those
+though
+three
+through
+throughout
+thru
+thus
+to
+together
+too
+took
+toward
+towards
+tried
+tries
+truly
+try
+trying
+t's
+twice
+two
+un
+under
+unfortunately
+unless
+unlikely
+until
+unto
+up
+upon
+us
+use
+used
+useful
+uses
+using
+usually
+value
+various
+very
+via
+viz
+vs
+want
+wants
+was
+wasn't
+way
+we
+we'd
+welcome
+well
+we'll
+went
+were
+we're
+weren't
+we've
+what
+whatever
+what's
+when
+whence
+whenever
+where
+whereafter
+whereas
+whereby
+wherein
+where's
+whereupon
+wherever
+whether
+which
+while
+whither
+who
+whoever
+whole
+whom
+who's
+whose
+why
+will
+willing
+wish
+with
+within
+without
+wonder
+won't
+would
+wouldn't
+yes
+yet
+you
+you'd
+you'll
+your
+you're
+yours
+yourself
+yourselves
+you've
+zero
+zt
+ZT
+zz
+ZZ
+一
+一下
+一些
+一切
+一则
+一天
+一定
+一方面
+一旦
+一时
+一来
+一样
+一次
+一片
+一直
+一致
+一般
+一起
+一边
+一面
+万一
+上下
+上升
+上去
+上来
+上述
+上面
+下列
+下去
+下来
+下面
+不一
+不久
+不仅
+不会
+不但
+不光
+不单
+不变
+不只
+不可
+不同
+不够
+不如
+不得
+不怕
+不惟
+不成
+不拘
+不敢
+不断
+不是
+不比
+不然
+不特
+不独
+不管
+不能
+不要
+不论
+不足
+不过
+不问
+与
+与其
+与否
+与此同时
+专门
+且
+两者
+严格
+严重
+个
+个人
+个别
+中小
+中间
+丰富
+临
+为
+为主
+为了
+为什么
+为什麽
+为何
+为着
+主张
+主要
+举行
+乃
+乃至
+么
+之
+之一
+之前
+之后
+之後
+之所以
+之类
+乌乎
+乎
+乘
+也
+也好
+也是
+也罢
+了
+了解
+争取
+于
+于是
+于是乎
+云云
+互相
+产生
+人们
+人家
+什么
+什么样
+什麽
+今后
+今天
+今年
+今後
+仍然
+从
+从事
+从而
+他
+他人
+他们
+他的
+代替
+以
+以上
+以下
+以为
+以便
+以免
+以前
+以及
+以后
+以外
+以後
+以来
+以至
+以至于
+以致
+们
+任
+任何
+任凭
+任务
+企图
+伟大
+似乎
+似的
+但
+但是
+何
+何况
+何处
+何时
+作为
+你
+你们
+你的
+使得
+使用
+例如
+依
+依照
+依靠
+促进
+保持
+俺
+俺们
+倘
+倘使
+倘或
+倘然
+倘若
+假使
+假如
+假若
+做到
+像
+允许
+充分
+先后
+先後
+先生
+全部
+全面
+兮
+共同
+关于
+其
+其一
+其中
+其二
+其他
+其余
+其它
+其实
+其次
+具体
+具体地说
+具体说来
+具有
+再者
+再说
+冒
+冲
+决定
+况且
+准备
+几
+几乎
+几时
+凭
+凭借
+出去
+出来
+出现
+分别
+则
+别
+别的
+别说
+到
+前后
+前者
+前进
+前面
+加之
+加以
+加入
+加强
+十分
+即
+即令
+即使
+即便
+即或
+即若
+却不
+原来
+又
+及
+及其
+及时
+及至
+双方
+反之
+反应
+反映
+反过来
+反过来说
+取得
+受到
+变成
+另
+另一方面
+另外
+只是
+只有
+只要
+只限
+叫
+叫做
+召开
+叮咚
+可
+可以
+可是
+可能
+可见
+各
+各个
+各人
+各位
+各地
+各种
+各级
+各自
+合理
+同
+同一
+同时
+同样
+后来
+后面
+向
+向着
+吓
+吗
+否则
+吧
+吧哒
+吱
+呀
+呃
+呕
+呗
+呜
+呜呼
+呢
+周围
+呵
+呸
+呼哧
+咋
+和
+咚
+咦
+咱
+咱们
+咳
+哇
+哈
+哈哈
+哉
+哎
+哎呀
+哎哟
+哗
+哟
+哦
+哩
+哪
+哪个
+哪些
+哪儿
+哪天
+哪年
+哪怕
+哪样
+哪边
+哪里
+哼
+哼唷
+唉
+啊
+啐
+啥
+啦
+啪达
+喂
+喏
+喔唷
+嗡嗡
+嗬
+嗯
+嗳
+嘎
+嘎登
+嘘
+嘛
+嘻
+嘿
+因
+因为
+因此
+因而
+固然
+在
+在下
+地
+坚决
+坚持
+基本
+处理
+复杂
+多
+多少
+多数
+多次
+大力
+大多数
+大大
+大家
+大批
+大约
+大量
+失去
+她
+她们
+她的
+好的
+好象
+如
+如上所述
+如下
+如何
+如其
+如果
+如此
+如若
+存在
+宁
+宁可
+宁愿
+宁肯
+它
+它们
+它们的
+它的
+安全
+完全
+完成
+实现
+实际
+宣布
+容易
+密切
+对
+对于
+对应
+将
+少数
+尔后
+尚且
+尤其
+就
+就是
+就是说
+尽
+尽管
+属于
+岂但
+左右
+巨大
+巩固
+己
+已经
+帮助
+常常
+并
+并不
+并不是
+并且
+并没有
+广大
+广泛
+应当
+应用
+应该
+开外
+开始
+开展
+引起
+强烈
+强调
+归
+当
+当前
+当时
+当然
+当着
+形成
+彻底
+彼
+彼此
+往
+往往
+待
+後来
+後面
+得
+得出
+得到
+心里
+必然
+必要
+必须
+怎
+怎么
+怎么办
+怎么样
+怎样
+怎麽
+总之
+总是
+总的来看
+总的来说
+总的说来
+总结
+总而言之
+恰恰相反
+您
+意思
+愿意
+慢说
+成为
+我
+我们
+我的
+或
+或是
+或者
+战斗
+所
+所以
+所有
+所谓
+打
+扩大
+把
+抑或
+拿
+按
+按照
+换句话说
+换言之
+据
+掌握
+接着
+接著
+故
+故此
+整个
+方便
+方面
+旁人
+无宁
+无法
+无论
+既
+既是
+既然
+时候
+明显
+明确
+是
+是否
+是的
+显然
+显著
+普通
+普遍
+更加
+曾经
+替
+最后
+最大
+最好
+最後
+最近
+最高
+有
+有些
+有关
+有利
+有力
+有所
+有效
+有时
+有点
+有的
+有着
+有著
+望
+朝
+朝着
+本
+本着
+来
+来着
+极了
+构成
+果然
+果真
+某
+某个
+某些
+根据
+根本
+欢迎
+正在
+正如
+正常
+此
+此外
+此时
+此间
+毋宁
+每
+每个
+每天
+每年
+每当
+比
+比如
+比方
+比较
+毫不
+没有
+沿
+沿着
+注意
+深入
+清楚
+满足
+漫说
+焉
+然则
+然后
+然後
+然而
+照
+照着
+特别是
+特殊
+特点
+现代
+现在
+甚么
+甚而
+甚至
+用
+由
+由于
+由此可见
+的
+的话
+目前
+直到
+直接
+相似
+相信
+相反
+相同
+相对
+相对而言
+相应
+相当
+相等
+省得
+看出
+看到
+看来
+看看
+看见
+真是
+真正
+着
+着呢
+矣
+知道
+确定
+离
+积极
+移动
+突出
+突然
+立即
+第
+等
+等等
+管
+紧接着
+纵
+纵令
+纵使
+纵然
+练习
+组成
+经
+经常
+经过
+结合
+结果
+给
+绝对
+继续
+继而
+维持
+综上所述
+罢了
+考虑
+者
+而
+而且
+而况
+而外
+而已
+而是
+而言
+联系
+能
+能否
+能够
+腾
+自
+自个儿
+自从
+自各儿
+自家
+自己
+自身
+至
+至于
+良好
+若
+若是
+若非
+范围
+莫若
+获得
+虽
+虽则
+虽然
+虽说
+行为
+行动
+表明
+表示
+被
+要
+要不
+要不是
+要不然
+要么
+要是
+要求
+规定
+觉得
+认为
+认真
+认识
+让
+许多
+论
+设使
+设若
+该
+说明
+诸位
+谁
+谁知
+赶
+起
+起来
+起见
+趁
+趁着
+越是
+跟
+转动
+转变
+转贴
+较
+较之
+边
+达到
+迅速
+过
+过去
+过来
+运用
+还是
+还有
+这
+这个
+这么
+这么些
+这么样
+这么点儿
+这些
+这会儿
+这儿
+这就是说
+这时
+这样
+这点
+这种
+这边
+这里
+这麽
+进入
+进步
+进而
+进行
+连
+连同
+适应
+适当
+适用
+逐步
+逐渐
+通常
+通过
+造成
+遇到
+遭到
+避免
+那
+那个
+那么
+那么些
+那么样
+那些
+那会儿
+那儿
+那时
+那样
+那边
+那里
+那麽
+部分
+鄙人
+采取
+里面
+重大
+重新
+重要
+鉴于
+问题
+防止
+阿
+附近
+限制
+除
+除了
+除此之外
+除非
+随
+随着
+随著
+集中
+需要
+非但
+非常
+非徒
+靠
+顺
+顺着
+首先
+高兴
+是不是
+说说
diff --git a/reme4/config/__init__.py b/reme4/config/__init__.py
new file mode 100644
index 00000000..c2903189
--- /dev/null
+++ b/reme4/config/__init__.py
@@ -0,0 +1,8 @@
+"""Config"""
+
+from .config_parser import parse_args, resolve_app_config
+
+__all__ = [
+ "parse_args",
+ "resolve_app_config",
+]
diff --git a/reme4/config/config_parser.py b/reme4/config/config_parser.py
new file mode 100644
index 00000000..92fce784
--- /dev/null
+++ b/reme4/config/config_parser.py
@@ -0,0 +1,219 @@
+"""Parser for YAML config with CLI argument overrides."""
+
+import json
+import os
+import re
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+# Config files are looked up relative to this module's directory
+_CONFIG_DIR = Path(__file__).parent
+# Extensions in priority order: yaml > yml > json when stems collide
+_SUPPORTED_EXTS = (".yaml", ".yml", ".json")
+_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?}")
+# Strings like "007" / "00501" must stay as strings, not be coerced to numbers
+_LEADING_ZERO_RE = re.compile(r"^-?0\d")
+
+
+def _repl(m: re.Match) -> str:
+ name: str = m.group(1)
+ # group(2) is None when the placeholder has no `:-default` part
+ default: str | None = m.group(2)
+ v = os.environ.get(name)
+ if v is None:
+ if default is not None:
+ return default
+ raise ValueError(f"Config references undefined env var: {name}")
+ return v
+
+
+def _expand_env_vars(value: Any) -> Any:
+ """Recursively expand `${VAR}` / `${VAR:-default}` placeholders in strings."""
+ if isinstance(value, str):
+ return _ENV_VAR_RE.sub(_repl, value)
+ if isinstance(value, dict):
+ return {k: _expand_env_vars(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_expand_env_vars(v) for v in value]
+ return value
+
+
+def _discover_configs() -> dict[str, Path]:
+ """Pre-scan config directory: maps file stem (name without ext) -> Path."""
+ discovered: dict[str, Path] = {}
+ if _CONFIG_DIR.is_dir():
+ # Sort by ext priority so registration order is deterministic across filesystems
+ files = sorted(
+ (p for p in _CONFIG_DIR.iterdir() if p.is_file() and p.suffix in _SUPPORTED_EXTS),
+ key=lambda p: (_SUPPORTED_EXTS.index(p.suffix), p.name),
+ )
+ for p in files:
+ discovered.setdefault(p.stem, p)
+ return discovered
+
+
+_CONFIG_REGISTRY = _discover_configs()
+
+
+def parse_dot_notation(dot_list: list[str]) -> dict:
+ """Parse "key.subkey=value" strings into nested dict."""
+ result: dict = {}
+ for item in dot_list:
+ if "=" not in item:
+ raise ValueError(f"Invalid dot notation format (missing '='): {item}")
+ key_path, value_str = item.split("=", 1)
+ keys = key_path.split(".")
+ current = result
+ for key in keys[:-1]:
+ if key in current and not isinstance(current[key], dict):
+ raise ValueError(f"Cannot set nested key '{key_path}': '{key}' is already a value")
+ current = current.setdefault(key, {})
+ # Symmetric to the prefix check above: refuse scalar-over-dict overwrite
+ last_key = keys[-1]
+ if last_key in current and isinstance(current[last_key], dict):
+ raise ValueError(f"Cannot overwrite nested dict at '{key_path}' with scalar value")
+ current[last_key] = _convert_value(value_str)
+ return result
+
+
+def _convert_value(value_str: str) -> Any:
+ """Convert string to appropriate Python type.
+
+ Only converts "true"/"false" (case-insensitive) to boolean.
+ Use JSON format (e.g., '"yes"', '"no"') to preserve these as strings.
+ Leading-zero strings (e.g., "007", "00501") are kept as strings.
+ """
+ s = value_str.strip()
+ lower = s.lower()
+
+ # Handle special values (null, bool)
+ if lower in ("none", "null"):
+ return None
+ if lower == "true":
+ return True
+ if lower == "false":
+ return False
+
+ # Skip int/float for leading-zero strings to keep zip codes / ids intact
+ if not _LEADING_ZERO_RE.match(s):
+ for converter in (int, float):
+ try:
+ return converter(s)
+ except ValueError:
+ continue
+
+ # JSON handles lists, dicts, and explicitly-quoted strings
+ try:
+ return json.loads(s)
+ except (ValueError, json.JSONDecodeError):
+ pass
+
+ # Fallback to original string
+ return s
+
+
+def _load_config(name_or_path: str, encoding: str = "utf-8") -> dict:
+ """Load a YAML or JSON config file.
+
+ First check if name_or_path matches a pre-discovered config (key in _CONFIG_REGISTRY).
+ If not, treat as a file path and load directly.
+ """
+ # 1. Try pre-discovered configs first
+ if name_or_path in _CONFIG_REGISTRY:
+ return _read_config_file(_CONFIG_REGISTRY[name_or_path], encoding)
+
+ # 2. Treat as file path
+ p = Path(name_or_path)
+ if p.suffix in _SUPPORTED_EXTS:
+ if not p.exists():
+ raise FileNotFoundError(f"Config file not found: {p}")
+ return _read_config_file(p, encoding)
+
+ known = ", ".join(sorted(_CONFIG_REGISTRY)) if _CONFIG_REGISTRY else "none"
+ raise FileNotFoundError(f"Config file not found: {name_or_path}. Available: {known}")
+
+
+def _read_config_file(path: Path, encoding: str = "utf-8") -> dict:
+ """Read YAML or JSON file based on extension. Expands ${ENV_VAR}."""
+ with path.open(encoding=encoding) as f:
+ if path.suffix == ".json":
+ result = json.load(f)
+ else:
+ result = yaml.safe_load(f)
+ if result is None:
+ return {}
+ return _expand_env_vars(result)
+
+
+def _deep_merge(base: dict, update: dict) -> dict:
+ """Recursively merge dicts."""
+ result = base.copy()
+ for k, v in update.items():
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
+ result[k] = _deep_merge(result[k], v)
+ else:
+ result[k] = v
+ return result
+
+
+def _strip_arg_dashes(arg: str) -> str:
+ """Strip a single leading `--` or `-` prefix (not all leading dashes)."""
+ if arg.startswith("--"):
+ return arg[2:]
+ if arg.startswith("-"):
+ return arg[1:]
+ return arg
+
+
+def parse_args(*args) -> tuple[str, dict]:
+ """Parse CLI args: first arg is action, rest are key=value pairs.
+
+ Usage: reme app config=paw.yaml service.name=test
+ Returns: (action, parsed_kv_dict)
+ """
+ if not args:
+ raise ValueError("No arguments provided")
+
+ first = _strip_arg_dashes(args[0])
+ if "=" in first:
+ raise ValueError(f"First argument must be action, got: {args[0]}")
+
+ kvs: list[str] = []
+ for raw in args[1:]:
+ arg = _strip_arg_dashes(raw)
+ if "=" in arg:
+ kvs.append(arg)
+
+ parsed = parse_dot_notation(kvs) if kvs else {}
+ return first, parsed
+
+
+def resolve_app_config(**kwargs) -> dict:
+ """Resolve full app-start config: load `config=path` file, fall back to
+ `default`, then deep-merge with the remaining kwargs as overrides.
+ """
+ from ..utils import get_logger
+
+ logger = get_logger()
+ configs: list[dict] = []
+
+ # `config=path` arrives as a string here; `config.foo=bar` arrives as a
+ # nested dict and is left in `kwargs` to be merged as a normal override.
+ config_value = kwargs.get("config")
+ if isinstance(config_value, str):
+ kwargs.pop("config")
+ logger.info(f"Loading config: {config_value}")
+ configs.append(_load_config(config_value))
+ elif "default" in _CONFIG_REGISTRY:
+ logger.info("No config specified, loading 'default'")
+ configs.append(_load_config("default"))
+
+ configs.append(kwargs)
+
+ merged: dict = {}
+ for cfg in configs:
+ merged = _deep_merge(merged, cfg)
+
+ return merged
diff --git a/reme4/config/default.yaml b/reme4/config/default.yaml
new file mode 100644
index 00000000..1a501912
--- /dev/null
+++ b/reme4/config/default.yaml
@@ -0,0 +1,169 @@
+service:
+ backend: http
+# backend: mcp
+
+jobs:
+ - backend: base
+ name: demo
+ description: "demo job description"
+ parameters:
+ type: object
+ properties:
+ query:
+ type: string
+ description: "query"
+ min_score:
+ type: number
+ description: "min score"
+ default: 0.5
+ required:
+ - query
+ steps:
+ - backend: demo_echo_step1
+ - backend: demo_echo_step2
+
+ - backend: base
+ name: version
+ description: "return reme4 package version"
+ parameters:
+ type: object
+ properties: {}
+ steps:
+ - backend: version_step
+
+ - backend: base
+ name: health_check
+ description: "return a concise health-check snapshot of reme4 components"
+ parameters:
+ type: object
+ properties: {}
+ steps:
+ - backend: health_check_step
+
+ - backend: base
+ name: help
+ description: "list all registered jobs with their metadata"
+ parameters:
+ type: object
+ properties: {}
+ steps:
+ - backend: help_step
+
+ - backend: base
+ name: reindex
+ description: "wipe the file store and rebuild it from the watcher's tracked files"
+ parameters:
+ type: object
+ properties: {}
+ steps:
+ - backend: reindex_step
+
+ - backend: base
+ name: search
+ description: "hybrid search over file_store: vector + keyword fused via RRF"
+ parameters:
+ type: object
+ properties:
+ query:
+ type: string
+ description: "search query"
+ limit:
+ type: integer
+ description: "max results to return"
+ default: 5
+ min_score:
+ type: number
+ description: "minimum fused score threshold (RRF scores are small; default 0 disables filter)"
+ default: 0.0
+ vector_weight:
+ type: number
+ description: "weight for vector results in [0, 1]; keyword weight = 1 - vector_weight"
+ default: 0.7
+ candidate_multiplier:
+ type: number
+ description: "candidate pool multiplier per branch (capped at 200)"
+ default: 3.0
+ expand_links:
+ type: boolean
+ description: "attach outlinks/inlinks (with neighbor meta) to each result"
+ default: true
+ max_links_per_direction:
+ type: integer
+ description: "max neighbors shown per direction per result"
+ default: 10
+ required:
+ - query
+ steps:
+ - backend: search_step
+
+ - backend: stream
+ name: stream_demo
+ description: "stream demo job: repeat query 10x and stream char-by-char"
+ parameters:
+ type: object
+ properties:
+ query:
+ type: string
+ description: "query to echo"
+ repeat:
+ type: integer
+ description: "number of times to repeat the query"
+ default: 10
+ interval:
+ type: number
+ description: "seconds between chunks"
+ default: 0.1
+ required:
+ - query
+ steps:
+ - backend: stream_demo_step1
+ - backend: stream_demo_step2
+
+components:
+ # 1. tokenizer — no dependencies
+ tokenizer:
+ default:
+ backend: regex
+
+ # 2. embedding_model — no dependencies
+ embedding_model:
+ default:
+ backend: openai
+ model_name: text-embedding-v4
+ dimensions: 1024
+
+ # 3. file_graph — no dependencies
+ file_graph:
+ default:
+ backend: local
+
+ # 4. file_parser — no dependencies
+ file_parser:
+ default:
+ backend: default
+
+ # 5. keyword_index — depends on tokenizer
+ keyword_index:
+ default:
+ backend: bm25
+ tokenizer: default
+
+ # 6. file_store — depends on embedding_model / keyword_index / file_graph
+ file_store:
+ default:
+ backend: local
+ store_name: default
+# embedding_model: default
+ embedding_model: ""
+ keyword_index: default
+ file_graph: default
+
+ # 7. file_watcher — depends on file_store / file_parser
+ file_watcher:
+ default:
+ backend: lite
+ watch_paths:
+ - MEMORY.md
+ - memory
+ file_store: default
+ file_parser: default
\ No newline at end of file
diff --git a/reme4/constants.py b/reme4/constants.py
new file mode 100644
index 00000000..fce66e64
--- /dev/null
+++ b/reme4/constants.py
@@ -0,0 +1,7 @@
+"""Constants"""
+
+REME_SERVICE_INFO = "REME_SERVICE_INFO"
+
+REME_DEFAULT_HOST = "127.0.0.1"
+
+REME_DEFAULT_PORT = 2333
diff --git a/reme4/enumeration/__init__.py b/reme4/enumeration/__init__.py
new file mode 100644
index 00000000..9887ddee
--- /dev/null
+++ b/reme4/enumeration/__init__.py
@@ -0,0 +1,9 @@
+"""Enumeration"""
+
+from .chunk_enum import ChunkEnum
+from .component_enum import ComponentEnum
+
+__all__ = [
+ "ChunkEnum",
+ "ComponentEnum",
+]
diff --git a/reme4/enumeration/chunk_enum.py b/reme4/enumeration/chunk_enum.py
new file mode 100644
index 00000000..d397be9e
--- /dev/null
+++ b/reme4/enumeration/chunk_enum.py
@@ -0,0 +1,21 @@
+"""Chunk enumeration module."""
+
+from enum import Enum
+
+
+class ChunkEnum(str, Enum):
+ """Enumeration of possible chunk categories for stream processing."""
+
+ THINK = "think"
+
+ CONTENT = "content"
+
+ TOOL_CALL = "tool_call"
+
+ TOOL_RESULT = "tool_result"
+
+ USAGE = "usage"
+
+ ERROR = "error"
+
+ DONE = "done"
diff --git a/reme4/enumeration/component_enum.py b/reme4/enumeration/component_enum.py
new file mode 100644
index 00000000..c3e30a89
--- /dev/null
+++ b/reme4/enumeration/component_enum.py
@@ -0,0 +1,37 @@
+"""Component enumeration module."""
+
+from enum import Enum
+
+
+class ComponentEnum(str, Enum):
+ """Enumeration of component types for dependency injection and registration."""
+
+ BASE = "base"
+
+ AS_LLM = "as_llm"
+
+ AS_LLM_FORMATTER = "as_llm_formatter"
+
+ AS_TOKEN_COUNTER = "as_token_counter"
+
+ EMBEDDING_MODEL = "embedding_model"
+
+ FILE_PARSER = "file_parser"
+
+ FILE_STORE = "file_store"
+
+ FILE_GRAPH = "file_graph"
+
+ FILE_WATCHER = "file_watcher"
+
+ KEYWORD_INDEX = "keyword_index"
+
+ SERVICE = "service"
+
+ CLIENT = "client"
+
+ STEP = "step"
+
+ JOB = "job"
+
+ TOKENIZER = "tokenizer"
diff --git a/reme4/reme.py b/reme4/reme.py
new file mode 100644
index 00000000..fe74bb7a
--- /dev/null
+++ b/reme4/reme.py
@@ -0,0 +1,43 @@
+"""ReMe memory management application entry point."""
+
+import asyncio
+import sys
+
+from .application import Application
+from .components import R
+from .config import parse_args, resolve_app_config
+from .enumeration import ComponentEnum
+from .utils import cli_find_reme, load_env, precheck_start
+
+
+class ReMe(Application):
+ """ReMe memory management application."""
+
+
+async def call_server(action: str, **kwargs):
+ """Call the appropriate server component."""
+ backend: str = kwargs.pop("backend", "http")
+ client_cls = R.get(ComponentEnum.CLIENT, backend)
+ async with client_cls(action=action, **kwargs) as client:
+ async for chunk in client():
+ print(chunk, end="", flush=True)
+ print()
+
+
+def main():
+ """Parse CLI arguments and launch the appropriate mode."""
+ action, kwargs = parse_args(*sys.argv[1:])
+ if action == "start":
+ load_env()
+ kwargs = resolve_app_config(**kwargs)
+ if not precheck_start(kwargs.get("service")):
+ return
+ ReMe(**kwargs).run_app()
+ elif action == "find_reme":
+ cli_find_reme()
+ else:
+ asyncio.run(call_server(action, **kwargs))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reme4/schema/__init__.py b/reme4/schema/__init__.py
new file mode 100644
index 00000000..0a54891b
--- /dev/null
+++ b/reme4/schema/__init__.py
@@ -0,0 +1,25 @@
+"""Schema"""
+
+from .application_config import ApplicationConfig, ComponentConfig, JobConfig
+from .emb_node import EmbNode
+from .file_chunk import FileChunk
+from .file_front_matter import FileFrontMatter
+from .file_link import FileLink
+from .file_node import FileNode
+from .request import Request
+from .response import Response
+from .stream_chunk import StreamChunk
+
+__all__ = [
+ "ApplicationConfig",
+ "ComponentConfig",
+ "JobConfig",
+ "EmbNode",
+ "FileChunk",
+ "FileFrontMatter",
+ "FileLink",
+ "FileNode",
+ "Request",
+ "Response",
+ "StreamChunk",
+]
diff --git a/reme4/schema/application_config.py b/reme4/schema/application_config.py
new file mode 100644
index 00000000..a1d78c5f
--- /dev/null
+++ b/reme4/schema/application_config.py
@@ -0,0 +1,45 @@
+"""Application configuration models."""
+
+import os
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from ..enumeration import ComponentEnum
+
+
+class ComponentConfig(BaseModel):
+ """Base config for a component; extra fields allowed for backend-specific options."""
+
+ model_config = ConfigDict(extra="allow")
+
+ backend: str = Field(default="", description="Backend implementation class name")
+
+
+class JobConfig(ComponentConfig):
+ """Config for a job — an ordered sequence of step components."""
+
+ name: str = Field(default="", description="Unique job identifier")
+ description: str = Field(default="", description="Human-readable description")
+ parameters: dict = Field(default_factory=dict, description="Job-level parameters")
+ steps: list[ComponentConfig] = Field(default_factory=list, description="Ordered step configs")
+
+
+class ApplicationConfig(BaseModel):
+ """Root config for the ReMe application."""
+
+ app_name: str = Field(default=os.getenv("APP_NAME", "ReMe"), description="Application display name")
+ working_dir: str = Field(default=".reme", description="Working directory for runtime files")
+ metadata_dir: str = Field(default="reme_metadata", description="Subdirectory for ReMe persistent state")
+ daily_dir: str = Field(default="daily", description="Subdirectory for daily memory")
+ knowledge_dir: str = Field(default="knowledge", description="Subdirectory for knowledge")
+ enable_logo: bool = Field(default=True, description="Show ASCII logo on startup")
+ language: str = Field(default="", description="Default language for LLM interactions")
+ log_to_console: bool = Field(default=True, description="Log to console")
+ log_to_file: bool = Field(default=True, description="Log to file")
+ mcp_servers: dict[str, dict] = Field(default_factory=dict, description="MCP server configs by name")
+ service: ComponentConfig = Field(default_factory=ComponentConfig, description="Service endpoint config")
+ jobs: list[JobConfig] = Field(default_factory=list, description="Job definitions")
+ components: dict[ComponentEnum, dict[str, ComponentConfig]] = Field(
+ default_factory=dict,
+ description="Component registry keyed by type then name",
+ )
diff --git a/reme4/schema/emb_node.py b/reme4/schema/emb_node.py
new file mode 100644
index 00000000..2474a4e6
--- /dev/null
+++ b/reme4/schema/emb_node.py
@@ -0,0 +1,34 @@
+"""Embedding node — base record carrying text and its vector."""
+
+from uuid import uuid4
+
+import numpy as np
+from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
+
+
+class EmbNode(BaseModel):
+ """A text record with an optional embedding vector and metadata."""
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ id: str = Field(default_factory=lambda: uuid4().hex, description="Unique node id")
+ text: str = Field(default="", description="Text content")
+ embedding: np.ndarray | None = Field(default=None, description="Embedding vector (float16)")
+ metadata: dict = Field(default_factory=dict, description="Arbitrary metadata")
+
+ @field_validator("embedding", mode="before")
+ @classmethod
+ def validate_embedding(cls, v):
+ """Coerce list/tuple to float16 ndarray."""
+ # Coerce list/tuple inputs into a float16 ndarray for compact storage.
+ if v is None:
+ return v
+ return np.array(v, dtype=np.float16)
+
+ @field_serializer("embedding")
+ def serialize_embedding(self, v: np.ndarray | None, _info):
+ """Serialize ndarray to a JSON-friendly list."""
+ # ndarray is not JSON-serializable; emit a plain list.
+ if v is None:
+ return None
+ return v.tolist()
diff --git a/reme4/schema/file_chunk.py b/reme4/schema/file_chunk.py
new file mode 100644
index 00000000..f7de723b
--- /dev/null
+++ b/reme4/schema/file_chunk.py
@@ -0,0 +1,26 @@
+"""File chunk — an embedding node tied to a line range in a file."""
+
+from pydantic import Field
+
+from .emb_node import EmbNode
+
+
+class FileChunk(EmbNode):
+ """A chunk of a file with positional info and per-stage retrieval scores."""
+
+ path: str = Field(default="", description="Vault-relative file path")
+ start_line: int = Field(default=0, description="Inclusive start line (0-based)")
+ end_line: int = Field(default=0, description="Exclusive end line")
+ scores: dict[str, float] = Field(default_factory=dict, description="Retrieval scores keyed by stage")
+
+ @property
+ def score(self) -> float:
+ """Final aggregated score; 0.0 if not yet computed."""
+ return self.scores.get("score", 0.0)
+
+ def set_hash_id(self):
+ """Replace ``id`` with a deterministic hash of (path, range, text)."""
+ from ..utils import hash_text
+
+ self.id = hash_text(" ".join([self.path, str(self.start_line), str(self.end_line), self.text]))
+ return self
diff --git a/reme4/schema/file_front_matter.py b/reme4/schema/file_front_matter.py
new file mode 100644
index 00000000..16e81537
--- /dev/null
+++ b/reme4/schema/file_front_matter.py
@@ -0,0 +1,24 @@
+"""FileFrontMatter — parsed Markdown front matter."""
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class FileFrontMatter(BaseModel):
+ """Markdown front matter; unknown keys are preserved as extras."""
+
+ model_config = ConfigDict(extra="allow")
+
+ title: str = Field(default="", description="Document title")
+ description: str = Field(default="", description="Document description")
+ tags: list[str] | None = Field(default=None, description="Tags; None if absent")
+
+ @property
+ def model_extra(self) -> dict[str, Any] | None:
+ """Get extra fields set during validation.
+
+ Returns:
+ A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`.
+ """
+ return self.__pydantic_extra__
diff --git a/reme4/schema/file_link.py b/reme4/schema/file_link.py
new file mode 100644
index 00000000..b029d5a9
--- /dev/null
+++ b/reme4/schema/file_link.py
@@ -0,0 +1,18 @@
+"""FileLink"""
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class FileLink(BaseModel):
+ """file link
+ [[target_path]]
+ [[target_path#target_anchor]]
+ predicate:: [[target_*]]
+ [predicate:: [[target_*]]]
+ """
+
+ model_config = ConfigDict(extra="forbid")
+ source_path: str = Field(default=..., description="source file path relative to working dir")
+ target_path: str = Field(default=..., description="target file path relative to working dir")
+ target_anchor: str | None = Field(default=None, description="Heading or block anchor (text after '#')")
+ predicate: str | None = Field(default=None, description="Dataview-style typed-link predicate")
diff --git a/reme4/schema/file_node.py b/reme4/schema/file_node.py
new file mode 100644
index 00000000..9be276f8
--- /dev/null
+++ b/reme4/schema/file_node.py
@@ -0,0 +1,16 @@
+"""File node — a file's metadata, links, and chunk references in the graph."""
+
+from pydantic import BaseModel, Field
+
+from .file_front_matter import FileFrontMatter
+from .file_link import FileLink
+
+
+class FileNode(BaseModel):
+ """A vault file as a graph node."""
+
+ path: str = Field(default=..., description="Vault-relative file path")
+ st_mtime: float = Field(default=..., description="Filesystem mtime (seconds)")
+ links: list[FileLink] = Field(default_factory=list, description="Outgoing wikilinks")
+ chunk_ids: list[str] = Field(default_factory=list, description="Owned FileChunk ids")
+ front_matter: FileFrontMatter = Field(default_factory=FileFrontMatter, description="Parsed front matter")
diff --git a/reme4/schema/request.py b/reme4/schema/request.py
new file mode 100644
index 00000000..c1d9b2ab
--- /dev/null
+++ b/reme4/schema/request.py
@@ -0,0 +1,11 @@
+"""Request schema for service endpoints."""
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class Request(BaseModel):
+ """Incoming service request; extra fields are allowed for endpoint-specific payloads."""
+
+ model_config = ConfigDict(extra="allow")
+
+ metadata: dict = Field(default_factory=dict, description="Request metadata for context")
diff --git a/reme4/schema/response.py b/reme4/schema/response.py
new file mode 100644
index 00000000..89cbce33
--- /dev/null
+++ b/reme4/schema/response.py
@@ -0,0 +1,15 @@
+"""Response schema for service endpoints and LLM calls."""
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class Response(BaseModel):
+ """Standard response envelope; extra fields allowed for endpoint-specific output."""
+
+ model_config = ConfigDict(extra="allow")
+
+ answer: str | Any = Field(default="", description="Response content or result data")
+ success: bool = Field(default=True, description="Whether the operation succeeded")
+ metadata: dict = Field(default_factory=dict, description="Additional context and diagnostics")
diff --git a/reme4/schema/stream_chunk.py b/reme4/schema/stream_chunk.py
new file mode 100644
index 00000000..b2fb0aef
--- /dev/null
+++ b/reme4/schema/stream_chunk.py
@@ -0,0 +1,14 @@
+"""Stream chunk schema for incremental responses (e.g. LLM streaming)."""
+
+from pydantic import BaseModel, Field
+
+from ..enumeration import ChunkEnum
+
+
+class StreamChunk(BaseModel):
+ """A single chunk in a streaming response sequence."""
+
+ chunk_type: ChunkEnum = Field(default=ChunkEnum.CONTENT, description="Type of chunk content")
+ chunk: str | dict | list = Field(default="", description="Chunk payload")
+ done: bool = Field(default=False, description="Whether this is the final chunk")
+ metadata: dict = Field(default_factory=dict, description="Chunk metadata")
diff --git a/reme4/steps/__init__.py b/reme4/steps/__init__.py
new file mode 100644
index 00000000..fa25273c
--- /dev/null
+++ b/reme4/steps/__init__.py
@@ -0,0 +1,9 @@
+"""steps"""
+
+from . import common
+from .base_step import BaseStep
+
+__all__ = [
+ "common",
+ "BaseStep",
+]
diff --git a/reme4/steps/base_step.py b/reme4/steps/base_step.py
new file mode 100644
index 00000000..a710f457
--- /dev/null
+++ b/reme4/steps/base_step.py
@@ -0,0 +1,140 @@
+"""Base step class for LLM workflow execution."""
+
+import copy
+from abc import abstractmethod, ABC
+from typing import TypeVar, TYPE_CHECKING
+
+from agentscope.formatter import FormatterBase
+from agentscope.model import ChatModelBase
+from agentscope.token import TokenCounterBase
+
+from ..components.embedding import BaseEmbeddingModel
+from ..components.file_parser import BaseFileParser
+from ..components.file_store import BaseFileStore
+from ..components.file_watcher import BaseFileWatcher
+from ..components.prompt_handler import PromptHandler
+from ..components.runtime_context import RuntimeContext
+from ..enumeration import ComponentEnum
+from ..utils import get_logger
+
+if TYPE_CHECKING:
+ from ..components import ApplicationContext
+
+T = TypeVar("T")
+
+
+class BaseStep(ABC):
+ """Composable unit of an LLM workflow."""
+
+ component_type = ComponentEnum.STEP
+
+ def __new__(cls, *args, **kwargs):
+ # Snapshot init args so copy() can rebuild an equivalent instance later.
+ instance = object.__new__(cls)
+ instance._init_args = copy.copy(args)
+ instance._init_kwargs = copy.copy(kwargs)
+ return instance
+
+ def __init__(
+ self,
+ name: str | None = None,
+ backend: str = "",
+ app_context: "ApplicationContext | None" = None,
+ language: str = "",
+ prompt_dict: dict[str, str] | None = None,
+ input_mapping: dict[str, str] | None = None,
+ output_mapping: dict[str, str] | None = None,
+ **kwargs,
+ ):
+ super().__init__()
+ self.name: str = name or self.__class__.__name__
+ self.backend: str = backend
+ self.app_context: "ApplicationContext | None" = app_context
+ self.language: str = language
+ self.input_mapping = input_mapping
+ self.output_mapping = output_mapping
+ self.kwargs: dict = kwargs
+ self.context: RuntimeContext | None = None
+
+ self.logger = get_logger()
+ if hasattr(self.logger, "bind"):
+ self.logger = self.logger.bind(component=self.name)
+
+ # Load class-level prompts first, then overlay caller-provided overrides.
+ self.prompt = PromptHandler(language=self.language)
+ self.prompt.load_prompt_by_class(self.__class__).load_prompt_dict(prompt_dict)
+
+ @abstractmethod
+ async def execute(self):
+ """Run the step's logic against ``self.context``."""
+
+ async def __call__(self, context: RuntimeContext | None = None, **kwargs):
+ # Build runtime context, then apply key remapping around execute().
+ self.context = RuntimeContext.from_context(context, **kwargs)
+ assert self.context is not None
+ if self.input_mapping:
+ self.context.apply_mapping(self.input_mapping)
+ result = await self.execute()
+ if self.output_mapping:
+ self.context.apply_mapping(self.output_mapping)
+ return result
+
+ def _resolve(self, key: str, base_cls: type[T], comp_enum: ComponentEnum, attr: str | None = None) -> T:
+ """Return a kwargs-supplied instance, or look one up by name in the app registry."""
+ # 1. Step init kwargs, 2. Runtime context (run_job kwargs), 3. App registry by name.
+ for source in (self.kwargs, self.context or {}):
+ value = source.get(key)
+ if isinstance(value, base_cls):
+ return value
+
+ name = self.kwargs.get(key, "default")
+ assert self.app_context is not None
+ comp = self.app_context.components[comp_enum][name]
+ return getattr(comp, attr) if attr else comp
+
+ @property
+ def as_llm(self) -> ChatModelBase:
+ """Return the chat model component."""
+ return self._resolve("as_llm", ChatModelBase, ComponentEnum.AS_LLM, "model")
+
+ @property
+ def as_llm_formatter(self) -> FormatterBase:
+ """Return the LLM formatter component."""
+ return self._resolve("as_llm_formatter", FormatterBase, ComponentEnum.AS_LLM_FORMATTER, "formatter")
+
+ @property
+ def as_token_counter(self) -> TokenCounterBase:
+ """Return the token counter component."""
+ return self._resolve("as_token_counter", TokenCounterBase, ComponentEnum.AS_TOKEN_COUNTER, "token_counter")
+
+ @property
+ def file_parser(self) -> BaseFileParser:
+ """Return the file parser component."""
+ return self._resolve("file_parser", BaseFileParser, ComponentEnum.FILE_PARSER)
+
+ @property
+ def file_store(self) -> BaseFileStore:
+ """Return the file store component."""
+ return self._resolve("file_store", BaseFileStore, ComponentEnum.FILE_STORE)
+
+ @property
+ def embedding(self) -> BaseEmbeddingModel:
+ """Return the embedding model component."""
+ return self._resolve("embedding", BaseEmbeddingModel, ComponentEnum.EMBEDDING_MODEL)
+
+ @property
+ def file_watcher(self) -> BaseFileWatcher:
+ """Return the file watcher component."""
+ return self._resolve("file_watcher", BaseFileWatcher, ComponentEnum.FILE_WATCHER)
+
+ def prompt_format(self, prompt_name: str, **kwargs) -> str:
+ """Format a named prompt template with the given kwargs."""
+ return self.prompt.prompt_format(prompt_name=prompt_name, **kwargs)
+
+ def get_prompt(self, prompt_name: str) -> str:
+ """Return a named prompt template as-is."""
+ return self.prompt.get_prompt(prompt_name=prompt_name)
+
+ def copy(self, **kwargs) -> "BaseStep":
+ """Construct a new instance from the original init args, applying overrides."""
+ return self.__class__(*self._init_args, **{**self._init_kwargs, **kwargs})
diff --git a/reme4/steps/common/__init__.py b/reme4/steps/common/__init__.py
new file mode 100644
index 00000000..e09c2073
--- /dev/null
+++ b/reme4/steps/common/__init__.py
@@ -0,0 +1,21 @@
+"""Common steps."""
+
+from .demo import DemoEchoStep1, DemoEchoStep2
+from .health_check import HealthCheckStep
+from .help import HelpStep
+from .reindex import ReindexStep
+from .search import SearchStep
+from .stream_demo import StreamDemoStep1, StreamDemoStep2
+from .version import VersionStep
+
+__all__ = [
+ "DemoEchoStep1",
+ "DemoEchoStep2",
+ "HealthCheckStep",
+ "HelpStep",
+ "ReindexStep",
+ "SearchStep",
+ "StreamDemoStep1",
+ "StreamDemoStep2",
+ "VersionStep",
+]
diff --git a/reme4/steps/common/demo.py b/reme4/steps/common/demo.py
new file mode 100644
index 00000000..e7c8df1a
--- /dev/null
+++ b/reme4/steps/common/demo.py
@@ -0,0 +1,53 @@
+"""Demo steps for smoke-testing the application stack."""
+
+from ..base_step import BaseStep
+from ...components import R
+
+
+@R.register("demo_echo_step1")
+class DemoEchoStep1(BaseStep):
+ """Read query/min_score from context, normalize, and write back for Step2."""
+
+ async def execute(self):
+ assert self.context is not None
+ query = self.context.get("query", "")
+ min_score = self.context.get("min_score", 0.5)
+
+ self.logger.info(f"[{self.name}] query={query!r}, min_score={min_score}")
+
+ processed_query = query.strip().lower()
+ adjusted_min_score = float(min_score) * 0.9
+
+ self.context["processed_query"] = processed_query
+ self.context["adjusted_min_score"] = adjusted_min_score
+
+ return self.context.response
+
+
+@R.register("demo_echo_step2")
+class DemoEchoStep2(BaseStep):
+ """Consume Step1's outputs from context and emit the final response."""
+
+ async def execute(self):
+ assert self.context is not None
+ query = self.context.get("query", "")
+ min_score = self.context.get("min_score", 0.5)
+ processed_query = self.context.get("processed_query", "")
+ adjusted_min_score = self.context.get("adjusted_min_score", min_score)
+
+ self.logger.info(
+ f"[{self.name}] query={query!r}, min_score={min_score}, "
+ f"processed_query={processed_query!r}, adjusted_min_score={adjusted_min_score}",
+ )
+
+ self.context.response.answer = f"echo: {processed_query} (min_score={adjusted_min_score})"
+ self.context.response.metadata.update(
+ {
+ "step": self.name,
+ "query": query,
+ "min_score": min_score,
+ "processed_query": processed_query,
+ "adjusted_min_score": adjusted_min_score,
+ },
+ )
+ return self.context.response
diff --git a/reme4/steps/common/health_check.py b/reme4/steps/common/health_check.py
new file mode 100644
index 00000000..6fc72652
--- /dev/null
+++ b/reme4/steps/common/health_check.py
@@ -0,0 +1,164 @@
+"""Return a concise health check snapshot of ReMe runtime components."""
+
+import sys
+from collections.abc import Mapping
+
+import numpy as np
+
+from ..base_step import BaseStep
+from ... import __version__
+from ...components import R
+from ...enumeration import ComponentEnum
+
+
+def _deep_size(obj, _seen: set | None = None) -> int:
+ """Recursive sizeof; uses ndarray.nbytes for numpy and walks containers / __dict__."""
+ if _seen is None:
+ _seen = set()
+ obj_id = id(obj)
+ if obj_id in _seen:
+ return 0
+ _seen.add(obj_id)
+
+ if isinstance(obj, np.ndarray):
+ return int(obj.nbytes) + sys.getsizeof(obj)
+
+ size = sys.getsizeof(obj)
+ if isinstance(obj, (str, bytes, bytearray, int, float, bool, type(None))):
+ return size
+ if isinstance(obj, Mapping):
+ size += sum(_deep_size(k, _seen) + _deep_size(v, _seen) for k, v in obj.items())
+ elif isinstance(obj, (list, tuple, set, frozenset)):
+ size += sum(_deep_size(item, _seen) for item in obj)
+ elif hasattr(obj, "__dict__"):
+ size += _deep_size(vars(obj), _seen)
+ elif hasattr(obj, "__slots__"):
+ for slot in obj.__slots__:
+ if hasattr(obj, slot):
+ size += _deep_size(getattr(obj, slot), _seen)
+ return size
+
+
+def _mb_str(*objs) -> str:
+ """Return summed deep size of objs formatted as 'X.XX MB'."""
+ seen: set = set()
+ total = sum(_deep_size(o, seen) for o in objs)
+ return f"{total / (1024 * 1024):.2f} MB"
+
+
+def _embedding_status(comp) -> dict:
+ return {
+ "is_started": comp.is_started,
+ "is_healthy": getattr(comp, "is_healthy", None),
+ "model_name": getattr(comp, "model_name", None),
+ "dimensions": getattr(comp, "dimensions", None),
+ "cache_size": len(getattr(comp, "_embedding_cache", {}) or {}),
+ "memory": _mb_str(getattr(comp, "_embedding_cache", {}) or {}),
+ }
+
+
+def _file_graph_status(comp) -> dict:
+ # Nx backend: single _graph attribute holds nodes/edges, virtuals are nodes without "node" payload.
+ g = getattr(comp, "_graph", None)
+ if g is not None:
+ n_real = sum(1 for _, d in g.nodes(data=True) if "node" in d)
+ return {
+ "is_started": comp.is_started,
+ "n_nodes": n_real,
+ "n_edges": g.number_of_edges(),
+ "n_virtual": g.number_of_nodes() - n_real,
+ "memory": _mb_str(g),
+ }
+ # Local backend: separate dicts for nodes, resolved inverse edges, and pending edges.
+ nodes = getattr(comp, "_nodes", {}) or {}
+ inverse = getattr(comp, "_inverse", {}) or {}
+ pending = getattr(comp, "_pending", {}) or {}
+ return {
+ "is_started": comp.is_started,
+ "n_nodes": len(nodes),
+ "n_edges": sum(len(s) for s in inverse.values()),
+ "n_pending": sum(len(s) for s in pending.values()),
+ "memory": _mb_str(nodes, inverse, pending),
+ }
+
+
+def _file_store_status(comp) -> dict:
+ chunks = getattr(comp, "file_chunks", {}) or {}
+ return {
+ "is_started": comp.is_started,
+ "n_chunks": len(chunks),
+ "n_chunks_with_embedding": sum(1 for c in chunks.values() if getattr(c, "embedding", None) is not None),
+ "memory": _mb_str(chunks),
+ }
+
+
+def _file_watcher_status(comp) -> dict:
+ bg = getattr(comp, "_background_task", None)
+ return {
+ "is_started": comp.is_started,
+ "background_running": bool(bg and not bg.done()),
+ "watch_paths": [str(p) for p in (getattr(comp, "watch_paths", []) or [])],
+ }
+
+
+def _keyword_index_status(comp) -> dict:
+ return {
+ "is_started": comp.is_started,
+ "n_docs": getattr(comp, "n_docs", None),
+ "vocab_size": len(getattr(comp, "vocab", {}) or {}),
+ "memory": _mb_str(
+ getattr(comp, "vocab", {}) or {},
+ getattr(comp, "inverted_index", {}) or {},
+ getattr(comp, "doc_meta", {}) or {},
+ getattr(comp, "_idf_cache", {}) or {},
+ ),
+ }
+
+
+_HANDLERS = {
+ ComponentEnum.EMBEDDING_MODEL: _embedding_status,
+ ComponentEnum.FILE_GRAPH: _file_graph_status,
+ ComponentEnum.FILE_STORE: _file_store_status,
+ ComponentEnum.FILE_WATCHER: _file_watcher_status,
+ ComponentEnum.KEYWORD_INDEX: _keyword_index_status,
+}
+
+
+def _is_status_healthy(ctype: ComponentEnum, status: dict) -> bool:
+ """Per-component health rule. Unstarted = unhealthy; type-specific extras checked."""
+ if not status.get("is_started"):
+ return False
+ if ctype is ComponentEnum.EMBEDDING_MODEL and status.get("is_healthy") is False:
+ return False
+ if ctype is ComponentEnum.FILE_WATCHER and not status.get("background_running"):
+ return False
+ return True
+
+
+@R.register("health_check_step")
+class HealthCheckStep(BaseStep):
+ """Collect a concise health check snapshot of the relevant components."""
+
+ async def execute(self):
+ assert self.context is not None
+
+ components: dict = {}
+ healthy = True
+ if self.app_context is not None:
+ for ctype, handler in _HANDLERS.items():
+ comp_map = self.app_context.components.get(ctype, {})
+ bucket = {}
+ for name, comp in comp_map.items():
+ s = handler(comp)
+ bucket[name] = s
+ if not _is_status_healthy(ctype, s):
+ healthy = False
+ components[ctype.value] = bucket
+
+ health = {"version": __version__, "healthy": healthy, "components": components}
+ self.logger.info(f"[{self.name}] health collected: {health}")
+
+ status_emoji = "✅" if healthy else "❌"
+ self.context.response.answer = f"{status_emoji} ReMe v{__version__} - {'healthy' if healthy else 'unhealthy'}"
+ self.context.response.metadata["health"] = health
+ return self.context.response
diff --git a/reme4/steps/common/help.py b/reme4/steps/common/help.py
new file mode 100644
index 00000000..6c850772
--- /dev/null
+++ b/reme4/steps/common/help.py
@@ -0,0 +1,42 @@
+"""Return a one-line summary of every registered job for LLM consumption."""
+
+from ..base_step import BaseStep
+from ...components import R
+
+
+@R.register("help_step")
+class HelpStep(BaseStep):
+ """List all registered jobs (excluding self) as compact one-liners for an LLM."""
+
+ @staticmethod
+ def _format_params(parameters: dict) -> str:
+ props = (parameters or {}).get("properties") or {}
+ if not props:
+ return "no args"
+ required = set((parameters or {}).get("required") or [])
+ parts = []
+ for pname, pschema in props.items():
+ ptype = pschema.get("type", "any")
+ if pname in required:
+ parts.append(f"{pname}:{ptype}*")
+ elif "default" in pschema:
+ parts.append(f"{pname}:{ptype}={pschema['default']}")
+ else:
+ parts.append(f"{pname}:{ptype}")
+ return ", ".join(parts)
+
+ async def execute(self):
+ assert self.context is not None
+
+ lines = []
+ if self.app_context is not None:
+ for name, job in self.app_context.jobs.items():
+ if name == "help":
+ continue
+ lines.append(f"🛠️ `{name}` — {job.description} 📥 {self._format_params(job.parameters)}")
+
+ self.logger.info(f"[{self.name}] returning {len(lines)} jobs")
+
+ self.context.response.answer = "\n".join(lines)
+ self.context.response.metadata["job_count"] = len(lines)
+ return self.context.response
diff --git a/reme4/steps/common/reindex.py b/reme4/steps/common/reindex.py
new file mode 100644
index 00000000..f4d280e9
--- /dev/null
+++ b/reme4/steps/common/reindex.py
@@ -0,0 +1,24 @@
+"""Wipe the file store and rebuild it from the watcher's tracked files."""
+
+from ..base_step import BaseStep
+from ...components import R
+
+
+@R.register("reindex_step")
+class ReindexStep(BaseStep):
+ """Full re-index: stop watcher, clear store, sync from disk, then restart."""
+
+ async def execute(self):
+ assert self.context is not None
+
+ await self.file_watcher.close()
+ try:
+ await self.file_store.clear()
+ counts = await self.file_watcher.update_store()
+ finally:
+ await self.file_watcher.start()
+
+ self.logger.info(f"[{self.name}] reindexed {counts}")
+ self.context.response.answer = f"🔄 Reindexed {counts['added']} file(s)"
+ self.context.response.metadata["counts"] = counts
+ return self.context.response
diff --git a/reme4/steps/common/search.py b/reme4/steps/common/search.py
new file mode 100644
index 00000000..595d665e
--- /dev/null
+++ b/reme4/steps/common/search.py
@@ -0,0 +1,227 @@
+"""Hybrid search over file_store using RRF fusion of vector + keyword results."""
+
+import asyncio
+
+from ..base_step import BaseStep
+from ...components import R
+from ...schema import FileChunk, FileLink, FileNode
+
+_RRF_K = 60
+_MAX_CANDIDATES = 200
+
+
+@R.register("search_step")
+class SearchStep(BaseStep):
+ """Hybrid search: run vector + keyword in parallel, fuse via RRF, filter, truncate."""
+
+ @staticmethod
+ def _rrf_merge(
+ vector: list[FileChunk],
+ keyword: list[FileChunk],
+ vector_weight: float,
+ ) -> list[FileChunk]:
+ """Fuse two ranked lists with Reciprocal Rank Fusion, keyed by chunk.id."""
+ text_weight = 1.0 - vector_weight
+ merged: dict[str, FileChunk] = {}
+
+ for rank, chunk in enumerate(vector, start=1):
+ contrib = vector_weight / (_RRF_K + rank)
+ c = chunk.model_copy(deep=False)
+ c.scores = {**chunk.scores, "vector": chunk.scores.get("vector", chunk.score), "score": contrib}
+ merged[c.id] = c
+
+ for rank, chunk in enumerate(keyword, start=1):
+ contrib = text_weight / (_RRF_K + rank)
+ existing = merged.get(chunk.id)
+ if existing is not None:
+ existing.scores = {
+ **existing.scores,
+ "keyword": chunk.scores.get("keyword", chunk.score),
+ "score": existing.scores["score"] + contrib,
+ }
+ else:
+ c = chunk.model_copy(deep=False)
+ c.scores = {**chunk.scores, "keyword": chunk.scores.get("keyword", chunk.score), "score": contrib}
+ merged[c.id] = c
+
+ results = list(merged.values())
+ results.sort(key=lambda r: r.score, reverse=True)
+ return results
+
+ @staticmethod
+ def _format_scores(scores: dict[str, float], hybrid: bool) -> str:
+ """Format scores for the answer line: always show fused; show per-branch when hybrid."""
+ parts = [f"score={scores.get('score', 0.0):.4f}"]
+ if hybrid:
+ for k in ("vector", "keyword"):
+ v = scores.get(k)
+ parts.append(f"{k}={v:.4f}" if v is not None else f"{k}=-")
+ return " ".join(parts)
+
+ @staticmethod
+ def _group_by_neighbor(links: list[FileLink], key_attr: str) -> dict[str, list[dict]]:
+ """Group edges by neighbor path (insertion-ordered), each value a list of {predicate, anchor}."""
+ out: dict[str, list[dict]] = {}
+ for lnk in links:
+ neighbor = getattr(lnk, key_attr)
+ if not neighbor:
+ continue
+ out.setdefault(neighbor, []).append(
+ {"predicate": lnk.predicate, "anchor": lnk.target_anchor},
+ )
+ return out
+
+ @staticmethod
+ def _node_meta(node: FileNode | None) -> dict:
+ """Extract a compact meta dict (title/description/tags) from a FileNode."""
+ if node is None:
+ return {}
+ fm = node.front_matter
+ meta: dict = {}
+ if fm.title:
+ meta["title"] = fm.title
+ if fm.description:
+ meta["description"] = fm.description
+ if fm.tags:
+ meta["tags"] = list(fm.tags)
+ return meta
+
+ @staticmethod
+ def _format_meta_inline(meta: dict) -> str:
+ """One-line render of node meta for the answer; '(no meta)' when empty."""
+ parts = []
+ if "title" in meta:
+ parts.append(f'title="{meta["title"]}"')
+ if "tags" in meta:
+ parts.append(f"tags={meta['tags']}")
+ return " ".join(parts) if parts else "(no meta)"
+
+ @staticmethod
+ def _format_via(edge: dict) -> str:
+ """Render a single (predicate, anchor) edge as a 'via ...' descriptor."""
+ bits = []
+ if edge.get("predicate"):
+ bits.append(f"predicate={edge['predicate']}")
+ if edge.get("anchor"):
+ bits.append(f"anchor=#{edge['anchor']}")
+ return ", ".join(bits) if bits else "plain"
+
+ async def _expand_links(
+ self,
+ chunk_paths: list[str],
+ max_per_direction: int,
+ ) -> dict[str, dict]:
+ """Fetch out/in links for each chunk path; attach neighbor meta. Returns per-path expansion."""
+ if not chunk_paths:
+ return {}
+
+ out_lists, in_lists = await asyncio.gather(
+ asyncio.gather(*(self.file_store.get_outlinks(p) for p in chunk_paths)),
+ asyncio.gather(*(self.file_store.get_inlinks(p) for p in chunk_paths)),
+ )
+
+ # Pre-group + cap per direction so we only fetch meta for displayed neighbors.
+ out_grouped = [
+ dict(list(self._group_by_neighbor(outs, "target_path").items())[:max_per_direction]) for outs in out_lists
+ ]
+ in_grouped = [
+ dict(list(self._group_by_neighbor(ins, "source_path").items())[:max_per_direction]) for ins in in_lists
+ ]
+
+ neighbor_paths = sorted({n for g in out_grouped for n in g} | {n for g in in_grouped for n in g})
+ nodes = await self.file_store.get_nodes(neighbor_paths) if neighbor_paths else []
+ meta_by_path = {n.path: self._node_meta(n) for n in nodes}
+
+ def _attach(grouped: dict[str, list[dict]]) -> list[dict]:
+ return [
+ {"path": npath, "meta": meta_by_path.get(npath, {}), "edges": edges} for npath, edges in grouped.items()
+ ]
+
+ return {
+ cp: {"outlinks": _attach(og), "inlinks": _attach(ig)}
+ for cp, og, ig in zip(chunk_paths, out_grouped, in_grouped)
+ }
+
+ @classmethod
+ def _render_expansion_lines(cls, expansion: dict) -> list[str]:
+ """Render outlinks/inlinks blocks for one chunk path; return zero or more indented lines."""
+ lines: list[str] = []
+ for direction, arrow, items in (
+ ("outlinks", "→", expansion.get("outlinks") or []),
+ ("inlinks", "←", expansion.get("inlinks") or []),
+ ):
+ if not items:
+ continue
+ lines.append(f" {direction} ({len(items)}):")
+ for item in items:
+ lines.append(f" {arrow} {item['path']} {cls._format_meta_inline(item['meta'])}")
+ for edge in item["edges"]:
+ lines.append(f" via {cls._format_via(edge)}")
+ return lines
+
+ async def execute(self):
+ assert self.context is not None
+ query: str = (self.context.get("query", "") or "").strip()
+ limit: int = int(self.context.get("limit", 5))
+ min_score: float = float(self.context.get("min_score", 0.0))
+ vector_weight: float = float(self.context.get("vector_weight", 0.7))
+ candidate_multiplier: float = float(self.context.get("candidate_multiplier", 3.0))
+ expand_links: bool = bool(self.context.get("expand_links", True))
+ max_links_per_direction: int = int(self.context.get("max_links_per_direction", 10))
+
+ assert query, "query cannot be empty"
+ assert 0.0 <= vector_weight <= 1.0, f"vector_weight must be in [0, 1], got {vector_weight}"
+ assert limit > 0, f"limit must be positive, got {limit}"
+
+ candidates = min(_MAX_CANDIDATES, max(1, int(limit * candidate_multiplier)))
+ search_filter: dict = self.context.get("search_filter", {}) or {}
+
+ vector_results, keyword_results = await asyncio.gather(
+ self.file_store.vector_search(query, candidates, search_filter),
+ self.file_store.keyword_search(query, candidates, search_filter),
+ )
+
+ self.logger.info(
+ f"[{self.name}] query={query!r} candidates={candidates} "
+ f"vector_hits={len(vector_results)} keyword_hits={len(keyword_results)}",
+ )
+
+ hybrid = bool(vector_results) and bool(keyword_results)
+ if not vector_results and not keyword_results:
+ fused: list[FileChunk] = []
+ elif not keyword_results:
+ fused = vector_results
+ elif not vector_results:
+ fused = keyword_results
+ else:
+ fused = self._rrf_merge(vector_results, keyword_results, vector_weight)
+
+ if min_score > 0.0:
+ fused = [c for c in fused if c.score >= min_score]
+ fused = fused[:limit]
+
+ unique_paths = list(dict.fromkeys(c.path for c in fused))
+ link_expansion: dict[str, dict] = (
+ await self._expand_links(unique_paths, max_links_per_direction) if expand_links else {}
+ )
+
+ answer_lines: list[str] = []
+ for c in fused:
+ answer_lines.append(
+ f"========== {c.path}:{c.start_line}-{c.end_line} "
+ f"[{self._format_scores(c.scores, hybrid)}] ==========\n{c.text}",
+ )
+ answer_lines.extend(self._render_expansion_lines(link_expansion.get(c.path, {})))
+
+ self.context.response.answer = "\n".join(answer_lines)
+ self.context.response.metadata["results"] = [
+ c.model_dump(exclude_none=True, exclude={"embedding"}) for c in fused
+ ]
+ self.context.response.metadata["link_expansion"] = link_expansion
+ self.context.response.metadata["counts"] = {
+ "vector": len(vector_results),
+ "keyword": len(keyword_results),
+ "returned": len(fused),
+ "hybrid": hybrid,
+ }
+ return self.context.response
diff --git a/reme4/steps/common/stream_demo.py b/reme4/steps/common/stream_demo.py
new file mode 100644
index 00000000..450077aa
--- /dev/null
+++ b/reme4/steps/common/stream_demo.py
@@ -0,0 +1,42 @@
+"""Streaming demo steps: step1 prepares text, step2 streams it char-by-char."""
+
+import asyncio
+
+from ..base_step import BaseStep
+from ...components import R
+from ...enumeration import ChunkEnum
+
+
+@R.register("stream_demo_step1")
+class StreamDemoStep1(BaseStep):
+ """Read query from context, repeat it 10x, write back for Step2 to stream."""
+
+ async def execute(self):
+ assert self.context is not None
+ query = self.context.get("query", "")
+ repeat = int(self.context.get("repeat", 10))
+
+ stream_text = (query * repeat) if query else ""
+
+ self.logger.info(f"[{self.name}] query={query!r}, repeat={repeat}, len={len(stream_text)}")
+
+ self.context["stream_text"] = stream_text
+ return self.context.response
+
+
+@R.register("stream_demo_step2")
+class StreamDemoStep2(BaseStep):
+ """Stream stream_text char-by-char as CONTENT chunks with 0.1s pacing."""
+
+ async def execute(self):
+ assert self.context is not None
+ stream_text: str = self.context.get("stream_text", "")
+ interval = float(self.context.get("interval", 0.1))
+
+ self.logger.info(f"[{self.name}] streaming {len(stream_text)} chars, interval={interval}s")
+
+ for ch in stream_text:
+ await self.context.add_stream_string(ch, ChunkEnum.CONTENT)
+ await asyncio.sleep(interval)
+
+ return self.context.response
diff --git a/reme4/steps/common/version.py b/reme4/steps/common/version.py
new file mode 100644
index 00000000..e43aa698
--- /dev/null
+++ b/reme4/steps/common/version.py
@@ -0,0 +1,19 @@
+"""Return the package version."""
+
+from ..base_step import BaseStep
+
+from ...components import R
+
+
+@R.register("version_step")
+class VersionStep(BaseStep):
+ """Emit reme4.__version__ as the response answer."""
+
+ async def execute(self):
+ assert self.context is not None
+ from ... import __version__
+
+ self.logger.info(f"[{self.name}] version={__version__}")
+ self.context.response.answer = __version__
+ self.context.response.metadata["version"] = __version__
+ return self.context.response
diff --git a/reme4/utils/__init__.py b/reme4/utils/__init__.py
new file mode 100644
index 00000000..45a5bfb9
--- /dev/null
+++ b/reme4/utils/__init__.py
@@ -0,0 +1,31 @@
+"""Utility modules."""
+
+from .common_utils import (
+ hash_text,
+ execute_stream_task,
+ mock_reme_server,
+ call_action,
+ call_and_check,
+)
+from .env_utils import load_env
+from .logger_utils import get_logger
+from .logo_utils import print_logo
+from .service_utils import find_reme, locate_reme, precheck_start, cli_find_reme
+from .similarity_utils import cosine_similarity, batch_cosine_similarity
+
+__all__ = [
+ "hash_text",
+ "execute_stream_task",
+ "mock_reme_server",
+ "call_action",
+ "call_and_check",
+ "load_env",
+ "get_logger",
+ "print_logo",
+ "find_reme",
+ "locate_reme",
+ "precheck_start",
+ "cli_find_reme",
+ "cosine_similarity",
+ "batch_cosine_similarity",
+]
diff --git a/reme4/utils/common_utils.py b/reme4/utils/common_utils.py
new file mode 100644
index 00000000..7df0dbef
--- /dev/null
+++ b/reme4/utils/common_utils.py
@@ -0,0 +1,249 @@
+"""Common utilities: hashing and async stream task execution."""
+
+import asyncio
+import hashlib
+import json
+import socket
+import subprocess
+import sys
+import time
+from collections.abc import AsyncGenerator, Callable
+from contextlib import asynccontextmanager
+from typing import Any, Literal
+
+from .logger_utils import get_logger
+from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
+from ..enumeration import ChunkEnum
+from ..schema import StreamChunk
+
+
+def hash_text(text: str, encoding: str = "utf-8") -> str:
+ """Return SHA-256 hex digest of text."""
+ return hashlib.sha256(text.encode(encoding)).hexdigest()
+
+
+def _format_chunk(
+ chunk: StreamChunk,
+ output_format: Literal["str", "bytes", "chunk"],
+) -> str | bytes | StreamChunk:
+ """Render a StreamChunk in the requested transport format."""
+ if output_format == "chunk":
+ return chunk
+ data = "data:[DONE]\n\n" if chunk.done else f"data:{chunk.model_dump_json()}\n\n"
+ return data.encode() if output_format == "bytes" else data
+
+
+async def execute_stream_task(
+ stream_queue: asyncio.Queue[StreamChunk],
+ task: asyncio.Task[Any],
+ task_name: str | None = None,
+ output_format: Literal["str", "bytes", "chunk"] = "str",
+) -> AsyncGenerator[str | bytes | StreamChunk, None]:
+ """Yield chunks from stream_queue while monitoring task; cancels task on exit.
+
+ output_format: "str"/"bytes" emit SSE frames, "chunk" emits raw StreamChunk.
+ """
+ logger = get_logger()
+ consumer: asyncio.Task[StreamChunk] | None = None
+ try:
+ while True:
+ consumer = get_chunk = asyncio.create_task(stream_queue.get())
+ done, _pending = await asyncio.wait({get_chunk, task}, return_when=asyncio.FIRST_COMPLETED)
+
+ # Producer still running — relay the next chunk and continue.
+ if task not in done:
+ chunk = get_chunk.result()
+ yield _format_chunk(chunk, output_format)
+ if chunk.done:
+ return
+ continue
+
+ # Producer finished. Capture any pending chunk, then stop the consumer wait
+ # so we can inspect task state safely.
+ pending_chunk: StreamChunk | None = None
+ if get_chunk in done:
+ pending_chunk = get_chunk.result()
+ else:
+ get_chunk.cancel()
+ try:
+ await get_chunk
+ except asyncio.CancelledError:
+ pass
+
+ # Surface task failure first — an exception trumps trailing data.
+ if task.cancelled():
+ msg = f"Task cancelled: {task_name}" if task_name else "Task cancelled"
+ raise asyncio.CancelledError(msg)
+ exc = task.exception()
+ if exc is not None:
+ log_msg = f"Task error in {task_name}: {exc}" if task_name else f"Task error: {exc}"
+ logger.error(log_msg, exc_info=exc)
+ raise exc
+
+ # Producer ended cleanly — flush pending + drain queue so no chunk is lost,
+ # then emit the terminal sentinel.
+ if pending_chunk is not None:
+ yield _format_chunk(pending_chunk, output_format)
+ if pending_chunk.done:
+ return
+ while not stream_queue.empty():
+ chunk = stream_queue.get_nowait()
+ yield _format_chunk(chunk, output_format)
+ if chunk.done:
+ return
+
+ yield _format_chunk(StreamChunk(chunk_type=ChunkEnum.DONE, chunk="", done=True), output_format)
+ return
+
+ finally:
+ # Cancel consumer wait if still pending (e.g. on consumer aclose).
+ if consumer is not None and not consumer.done():
+ consumer.cancel()
+ try:
+ await consumer
+ except asyncio.CancelledError:
+ pass
+ # Cancel producer task if still running to avoid resource leaks.
+ if not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+
+def _pick_free_port(host: str = REME_DEFAULT_HOST) -> int:
+ """Bind to port 0 and return the OS-assigned free port."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind((host, 0))
+ return s.getsockname()[1]
+
+
+async def _wait_reme_ready(host: str, port: int, timeout: float) -> None:
+ """Poll find_reme until it reports 'reme' or timeout elapses."""
+ from .service_utils import find_reme
+
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ status = await find_reme(host, port)
+ if status == "reme":
+ return
+ await asyncio.sleep(0.2)
+ raise TimeoutError(f"ReMe service did not become ready at {host}:{port} within {timeout}s")
+
+
+@asynccontextmanager
+async def mock_reme_server(
+ host: str = REME_DEFAULT_HOST,
+ port: int | None = None,
+ config: str | None = None,
+ extra_args: list[str] | None = None,
+ startup_timeout: float = 30.0,
+ shutdown_timeout: float = 10.0,
+ log_to_file: bool = False,
+ enable_logo: bool = False,
+):
+ """Spawn `reme4 start` as a subprocess and yield (host, port) once ready.
+
+ Auto-picks a free port when port is None. Subprocess is terminated on exit.
+ """
+ logger = get_logger()
+ if port is None:
+ port = _pick_free_port(host)
+
+ cmd: list[str] = [
+ sys.executable,
+ "-m",
+ "reme4.reme",
+ "start",
+ f"service.host={host}",
+ f"service.port={port}",
+ f"log_to_file={'true' if log_to_file else 'false'}",
+ f"enable_logo={'true' if enable_logo else 'false'}",
+ ]
+ if config:
+ cmd.append(f"config={config}")
+ if extra_args:
+ cmd.extend(extra_args)
+
+ logger.info(f"Launching mock reme server: {' '.join(cmd)}")
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ try:
+ await _wait_reme_ready(host, port, startup_timeout)
+ yield host, port
+ except Exception:
+ # Capture early-exit output for diagnostics.
+ if proc.poll() is not None and proc.stdout is not None:
+ tail = proc.stdout.read()
+ logger.error(f"reme server exited early. output:\n{tail}")
+ raise
+ finally:
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=shutdown_timeout)
+ except subprocess.TimeoutExpired:
+ logger.warning("reme server did not terminate gracefully, killing")
+ proc.kill()
+ proc.wait(timeout=shutdown_timeout)
+ if proc.stdout is not None:
+ try:
+ proc.stdout.close()
+ except Exception:
+ pass
+
+
+async def call_action(
+ action: str,
+ host: str = REME_DEFAULT_HOST,
+ port: int = REME_DEFAULT_PORT,
+ timeout: float = 30.0,
+ **kwargs,
+) -> dict | str:
+ """POST to /{action}; return parsed JSON (dict) for JSON endpoints, raw text for SSE."""
+ from ..components.client.http_client import HttpClient
+
+ pieces: list[str] = []
+ async with HttpClient(action=action, host=host, port=port, timeout=timeout, **kwargs) as client:
+ async for chunk in client.stream_chunks():
+ payload = chunk.chunk
+ pieces.append(payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False))
+ raw = "".join(pieces)
+ try:
+ return json.loads(raw)
+ except (ValueError, json.JSONDecodeError):
+ return raw
+
+
+async def call_and_check(
+ action: str,
+ host: str = REME_DEFAULT_HOST,
+ port: int = REME_DEFAULT_PORT,
+ validator: Callable[[Any], bool] | None = None,
+ expected: Any = None,
+ timeout: float = 30.0,
+ **kwargs,
+) -> Any:
+ """Call action and verify response. Raises AssertionError on mismatch.
+
+ - validator(result) -> bool: custom predicate.
+ - expected: deep-equality target (compared to result, or to result[key] when expected is dict).
+ """
+ result = await call_action(action, host=host, port=port, timeout=timeout, **kwargs)
+ if validator is not None and not validator(result):
+ raise AssertionError(f"validator rejected response for action={action!r}: {result!r}")
+ if expected is not None:
+ if isinstance(expected, dict) and isinstance(result, dict):
+ for k, v in expected.items():
+ if result.get(k) != v:
+ raise AssertionError(
+ f"action={action!r} expected {k}={v!r}, got {result.get(k)!r} (full: {result!r})",
+ )
+ elif result != expected:
+ raise AssertionError(f"action={action!r} expected {expected!r}, got {result!r}")
+ return result
diff --git a/reme4/utils/env_utils.py b/reme4/utils/env_utils.py
new file mode 100644
index 00000000..7c0aecbc
--- /dev/null
+++ b/reme4/utils/env_utils.py
@@ -0,0 +1,36 @@
+"""Load .env files into os.environ (idempotent)."""
+
+import os
+from pathlib import Path
+
+_LOADED = False
+
+
+def _parse(path: Path) -> None:
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ os.environ[key.strip()] = value.strip().strip("'\"")
+
+
+def load_env(path: str | Path | None = None) -> None:
+ """Load .env from given path, or search cwd and up to 5 parents."""
+ global _LOADED
+ if _LOADED:
+ return
+
+ if path:
+ path = Path(path)
+ if path.exists():
+ _parse(path)
+ _LOADED = True
+ return
+
+ for directory in [Path.cwd(), *Path.cwd().parents[:5]]:
+ env_path = directory / ".env"
+ if env_path.exists():
+ _parse(env_path)
+ _LOADED = True
+ return
diff --git a/reme4/utils/logger_utils.py b/reme4/utils/logger_utils.py
new file mode 100644
index 00000000..53d63b67
--- /dev/null
+++ b/reme4/utils/logger_utils.py
@@ -0,0 +1,108 @@
+"""Logger utilities supporting both loguru and standard logging backends."""
+
+import logging
+import os
+import sys
+from datetime import datetime
+from logging.handlers import TimedRotatingFileHandler
+
+_logger = None
+
+_LOGURU_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}"
+_STDLIB_FORMAT = "%(asctime)s | %(levelname)s | %(filename)s:%(lineno)d | %(funcName)s | %(message)s"
+_STDLIB_DATEFMT = "%Y-%m-%d %H:%M:%S"
+
+
+def _enable_loguru() -> bool:
+ return os.getenv("REME_DISABLE_LOGURU", "").lower() != "true"
+
+
+def _init_loguru(log_dir: str, level: str, log_to_console: bool, log_to_file: bool):
+ from loguru import logger
+
+ logger.remove()
+
+ if log_to_console:
+ logger.add(
+ sink=sys.stdout,
+ level=level,
+ format=_LOGURU_FORMAT,
+ colorize=True,
+ )
+
+ if log_to_file:
+ try:
+ os.makedirs(log_dir, exist_ok=True)
+ current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ log_filepath = os.path.join(log_dir, f"{current_ts}.log")
+
+ logger.add(
+ log_filepath,
+ level=level,
+ rotation="00:00",
+ retention="7 days",
+ compression="zip",
+ encoding="utf-8",
+ format=_LOGURU_FORMAT,
+ )
+ except Exception as e:
+ logger.error(f"Error configuring file logging: {e}")
+
+ return logger
+
+
+def _init_stdlib(log_dir: str, level: str, log_to_console: bool, log_to_file: bool):
+ logger = logging.getLogger("reme")
+ logger.setLevel(level)
+ logger.propagate = False
+
+ for handler in list(logger.handlers):
+ logger.removeHandler(handler)
+
+ formatter = logging.Formatter(_STDLIB_FORMAT, datefmt=_STDLIB_DATEFMT)
+
+ if log_to_console:
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setLevel(level)
+ console_handler.setFormatter(formatter)
+ logger.addHandler(console_handler)
+
+ if log_to_file:
+ try:
+ os.makedirs(log_dir, exist_ok=True)
+ current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ log_filepath = os.path.join(log_dir, f"{current_ts}.log")
+
+ file_handler = TimedRotatingFileHandler(
+ log_filepath,
+ when="midnight",
+ backupCount=7,
+ encoding="utf-8",
+ )
+ file_handler.setLevel(level)
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+ except Exception as e:
+ logger.error(f"Error configuring file logging: {e}")
+
+ return logger
+
+
+def get_logger(
+ log_dir: str = "logs",
+ level: str = "INFO",
+ log_to_console: bool = True,
+ log_to_file: bool = True,
+ force_init: bool = False,
+):
+ """Return the global logger, initializing sinks on first call (or when force_init)."""
+ global _logger
+
+ if _logger is not None and not force_init:
+ return _logger
+
+ if _enable_loguru():
+ _logger = _init_loguru(log_dir, level, log_to_console, log_to_file)
+ else:
+ _logger = _init_stdlib(log_dir, level, log_to_console, log_to_file)
+ return _logger
diff --git a/reme4/utils/logo_utils.py b/reme4/utils/logo_utils.py
new file mode 100644
index 00000000..2a70ab94
--- /dev/null
+++ b/reme4/utils/logo_utils.py
@@ -0,0 +1,87 @@
+"""Startup banner with ASCII logo and service metadata."""
+
+import importlib.metadata
+from typing import TYPE_CHECKING
+
+from rich.console import Console, Group
+from rich.panel import Panel
+from rich.table import Table
+from rich.text import Text
+
+if TYPE_CHECKING:
+ from ..schema import ApplicationConfig
+
+
+def get_version(package_name: str) -> str:
+ """Return installed package version, or empty string if not installed."""
+ try:
+ return importlib.metadata.version(package_name)
+ except importlib.metadata.PackageNotFoundError:
+ return ""
+
+
+def print_logo(app_config: "ApplicationConfig"):
+ """Print gradient ASCII logo and runtime config (backend, URL, versions)."""
+ ascii_art = [
+ r" ██████╗ ███████╗ ███╗ ███╗ ███████╗ ",
+ r" ██╔══██╗ ██╔════╝ ████╗ ████║ ██╔════╝ ",
+ r" ██████╔╝ █████╗ ██╔████╔██║ █████╗ ",
+ r" ██╔══██╗ ██╔══╝ ██║╚██╔╝██║ ██╔══╝ ",
+ r" ██║ ██║ ███████╗ ██║ ╚═╝ ██║ ███████╗ ",
+ r" ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ",
+ ]
+
+ start_color = (85, 239, 196)
+ end_color = (162, 155, 254)
+
+ logo_text = Text()
+ for line in ascii_art:
+ line_len = max(1, len(line) - 1)
+ for i, char in enumerate(line):
+ ratio = i / line_len
+ rgb = tuple(int(s + (e - s) * ratio) for s, e in zip(start_color, end_color))
+ logo_text.append(char, style=f"bold rgb({rgb[0]},{rgb[1]},{rgb[2]})")
+ logo_text.append("\n")
+
+ info_table = Table.grid(padding=(0, 1))
+ info_table.add_column(style="bold", justify="center")
+ info_table.add_column(style="bold cyan", justify="left")
+ info_table.add_column(style="white", justify="left")
+
+ # service is a ComponentConfig with extra="allow"; backend-specific fields live in model_extra.
+ service = app_config.service
+ backend = service.backend
+ extra = service.model_extra or {}
+
+ info_table.add_row("📦", "Backend:", backend)
+
+ match backend:
+ case "http":
+ host = extra.get("host", "localhost")
+ port = extra.get("port", 8000)
+ info_table.add_row("🔗", "URL:", f"http://{host}:{port}")
+ info_table.add_row("📚", "FastAPI:", Text(get_version("fastapi"), style="dim"))
+ case "mcp":
+ transport = extra.get("transport", "stdio")
+ info_table.add_row("🚌", "Transport:", transport)
+ if transport != "stdio":
+ host = extra.get("host", "localhost")
+ port = extra.get("port", 8000)
+ url = f"http://{host}:{port}"
+ if transport == "sse":
+ url += "/sse"
+ info_table.add_row("🔗", "URL:", url)
+ info_table.add_row("📚", "FastMCP:", Text(get_version("fastmcp"), style="dim"))
+
+ info_table.add_row("🚀", "ReMe:", Text(get_version("reme-ai"), style="dim"))
+
+ panel = Panel(
+ Group(logo_text, info_table),
+ title=app_config.app_name,
+ title_align="left",
+ border_style="dim",
+ padding=(1, 4),
+ expand=False,
+ )
+
+ Console().print(Group("\n", panel, "\n"))
diff --git a/reme4/utils/service_utils.py b/reme4/utils/service_utils.py
new file mode 100644
index 00000000..d1a39b9d
--- /dev/null
+++ b/reme4/utils/service_utils.py
@@ -0,0 +1,96 @@
+"""Service discovery utilities."""
+
+import asyncio
+import socket
+import subprocess
+import sys
+
+from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
+
+
+async def find_reme(host: str, port: int) -> str:
+ """Probe host:port. Returns 'reme', 'occupied', or 'free'."""
+ from ..components.client.http_client import HttpClient
+
+ try:
+ async with HttpClient(action="health_check", host=host, port=port, timeout=2.0) as client:
+ async for _ in client():
+ break
+ return "reme"
+ except Exception:
+ pass
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ s.bind((host, port))
+ return "free"
+ except OSError:
+ return "occupied"
+
+
+def _sh(cmd: list[str]) -> str:
+ """Run cmd; return stdout, or '' on failure."""
+ try:
+ return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return ""
+
+
+def _pid_on_port(port: int) -> int | None:
+ """PID listening on TCP port, or None."""
+ out = _sh(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"]).strip()
+ return int(out.splitlines()[0]) if out else None
+
+
+def _scan_reme_procs() -> list[tuple[int, str, int]]:
+ """List running 'reme ... start' processes as (pid, host, port)."""
+ procs: list[tuple[int, str, int]] = []
+ for line in _sh(["pgrep", "-af", "reme.* start"]).splitlines():
+ parts = line.split()
+ if not parts or not parts[0].isdigit():
+ continue
+ host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
+ for t in parts[1:]:
+ if t.startswith("service.host="):
+ host = t.split("=", 1)[1]
+ elif t.startswith("service.port=") and t.split("=", 1)[1].isdigit():
+ port = int(t.split("=", 1)[1])
+ procs.append((int(parts[0]), host, port))
+ return procs
+
+
+async def locate_reme() -> tuple[str, int, int | None] | None:
+ """Find a running reme: try default port, then scanned processes."""
+ if await find_reme(REME_DEFAULT_HOST, REME_DEFAULT_PORT) == "reme":
+ return REME_DEFAULT_HOST, REME_DEFAULT_PORT, _pid_on_port(REME_DEFAULT_PORT)
+ for pid, host, port in _scan_reme_procs():
+ if await find_reme(host, port) == "reme":
+ return host, port, pid
+ return None
+
+
+def precheck_start(svc_config: dict | None) -> bool:
+ """Pre-flight check for `start`: False if reme is up, exits 1 on port conflict."""
+ host = (svc_config or {}).get("host") or REME_DEFAULT_HOST
+ port = (svc_config or {}).get("port") or REME_DEFAULT_PORT
+ status = asyncio.run(find_reme(host, port))
+ if status == "reme":
+ print(f"reme already running at {host}:{port}")
+ return False
+ if status == "occupied":
+ print(
+ f"port {port} occupied. Start on another port: reme4 start service.port=",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+ return True
+
+
+def cli_find_reme() -> None:
+ """Handle `reme find_reme`: print HOST/PORT/PID or a hint to start reme."""
+ found = asyncio.run(locate_reme())
+ if not found:
+ print("reme not started. Try: reme start", file=sys.stderr)
+ sys.exit(1)
+ host, port, pid = found
+ print(f"HOST={host} PORT={port} PID={pid or 'unknown'}")
diff --git a/reme4/utils/similarity_utils.py b/reme4/utils/similarity_utils.py
new file mode 100644
index 00000000..ec0f7100
--- /dev/null
+++ b/reme4/utils/similarity_utils.py
@@ -0,0 +1,35 @@
+"""Cosine similarity for single vectors and batched matrices."""
+
+import numpy as np
+
+
+def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
+ """Cosine similarity of two equal-length vectors; returns 0.0 if either has zero norm."""
+ if len(vec1) != len(vec2):
+ raise ValueError(f"Vectors must have same length: {len(vec1)} != {len(vec2)}")
+
+ dot_product = sum(a * b for a, b in zip(vec1, vec2))
+ magnitude1 = sum(a * a for a in vec1) ** 0.5
+ magnitude2 = sum(b * b for b in vec2) ** 0.5
+
+ if magnitude1 == 0 or magnitude2 == 0:
+ return 0.0
+
+ return dot_product / (magnitude1 * magnitude2)
+
+
+def batch_cosine_similarity(nd_array1: np.ndarray, nd_array2: np.ndarray) -> np.ndarray:
+ """Pairwise cosine similarity matrix between two batches; output shape (N1, N2)."""
+ if nd_array1.shape[1] != nd_array2.shape[1]:
+ raise ValueError(
+ f"Embedding dimensions must match: {nd_array1.shape[1]} != {nd_array2.shape[1]}",
+ )
+
+ dot_products = np.dot(nd_array1, nd_array2.T)
+ norms1 = np.linalg.norm(nd_array1, axis=1)
+ norms2 = np.linalg.norm(nd_array2, axis=1)
+ norm_products = np.outer(norms1, norms2)
+ # Guard against zero-norm rows to keep division finite.
+ norm_products = np.where(norm_products == 0, 1e-10, norm_products)
+
+ return dot_products / norm_products
diff --git a/tests4/unittest/test_bm25_index_perf.py b/tests4/unittest/test_bm25_index_perf.py
new file mode 100644
index 00000000..b6ba0f34
--- /dev/null
+++ b/tests4/unittest/test_bm25_index_perf.py
@@ -0,0 +1,337 @@
+"""BM25Index performance tests for add_docs and retrieve."""
+
+import asyncio
+import os
+import random
+import tempfile
+import time
+
+from reme4.components.keyword_index import BM25Index
+from reme4.components.tokenizer import RegexTokenizer
+
+# A small vocab of realistic-looking words for generating random text
+_VOCAB = [
+ "algorithm",
+ "data",
+ "machine",
+ "learning",
+ "model",
+ "network",
+ "neural",
+ "training",
+ "optimization",
+ "gradient",
+ "loss",
+ "function",
+ "parameter",
+ "weight",
+ "bias",
+ "layer",
+ "activation",
+ "relu",
+ "sigmoid",
+ "softmax",
+ "backpropagation",
+ "forward",
+ "pass",
+ "batch",
+ "epoch",
+ "iteration",
+ "convergence",
+ "divergence",
+ "regularization",
+ "dropout",
+ "attention",
+ "transformer",
+ "encoder",
+ "decoder",
+ "embedding",
+ "token",
+ "vector",
+ "matrix",
+ "tensor",
+ "computation",
+ "graph",
+ "node",
+ "edge",
+ "vertex",
+ "path",
+ "search",
+ "retrieval",
+ "index",
+ "query",
+ "document",
+ "corpus",
+ "term",
+ "frequency",
+ "inverse",
+ "score",
+ "rank",
+ "relevance",
+ "precision",
+ "recall",
+ "f1",
+ "metric",
+ "evaluation",
+ "benchmark",
+ "dataset",
+ "sample",
+ "feature",
+ "label",
+ "class",
+ "predict",
+ "classification",
+ "regression",
+ "clustering",
+ "dimension",
+ "reduction",
+ "pca",
+ "tsne",
+ "visualization",
+ "matplotlib",
+ "plot",
+ "chart",
+ "histogram",
+ "scatter",
+ "line",
+ "bar",
+ "database",
+ "sql",
+ "query",
+ "table",
+ "row",
+ "column",
+ "index",
+ "primary",
+ "foreign",
+ "key",
+ "constraint",
+ "schema",
+ "migration",
+ "version",
+ "control",
+ "git",
+ "commit",
+ "branch",
+ "merge",
+ "conflict",
+ "resolution",
+ "review",
+ "approve",
+ "reject",
+ "pull",
+ "request",
+ "issue",
+ "bug",
+ "fix",
+ "feature",
+ "enhancement",
+ "refactor",
+ "test",
+ "deploy",
+ "production",
+ "staging",
+ "development",
+ "environment",
+ "configuration",
+ "setting",
+ "variable",
+ "constant",
+ "global",
+ "local",
+ "scope",
+ "closure",
+ "callback",
+ "promise",
+ "async",
+ "await",
+ "synchronous",
+ "asynchronous",
+ "concurrent",
+ "parallel",
+ "thread",
+ "process",
+ "memory",
+ "cache",
+ "buffer",
+ "queue",
+ "stack",
+ "heap",
+ "pool",
+]
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+def _gen_random_text(n_tokens: int) -> str:
+ """Generate random text with approximately n_tokens words."""
+ words = random.choices(_VOCAB, k=n_tokens)
+ return " ".join(words)
+
+
+def _gen_random_query(n_words: int) -> str:
+ """Generate a random query with n_words words."""
+ words = random.choices(_VOCAB, k=n_words)
+ return " ".join(words)
+
+
+async def _make_index() -> BM25Index:
+ """Create and start a BM25Index using cwd as working dir, with non-filtering tokenizer."""
+ index = BM25Index()
+ tokenizer = RegexTokenizer(filter_stopwords=False)
+ index.tokenizer = tokenizer
+ index._owned.append(tokenizer) # pylint: disable=protected-access
+ await index.start()
+ return index
+
+
+async def _setup_index_for_retrieve(n_docs: int = 100, doc_tokens: int = 1000) -> BM25Index:
+ """Build an index with n_docs medium-sized docs in cwd."""
+ index = await _make_index()
+ docs = {f"doc_{i}": _gen_random_text(doc_tokens) for i in range(n_docs)}
+ await index.add_docs(docs)
+ return index
+
+
+def test_add_docs_small():
+ """Add 100 small docs (~100 tokens each)."""
+
+ async def run():
+ docs = {f"doc_{i}": _gen_random_text(100) for i in range(100)}
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _make_index()
+ t0 = time.perf_counter()
+ await index.add_docs(docs)
+ elapsed = time.perf_counter() - t0
+ print(f" add_docs (100 docs x ~100 tokens): {elapsed:.4f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_add_docs_medium():
+ """Add 100 medium docs (~1000 tokens each)."""
+
+ async def run():
+ docs = {f"doc_{i}": _gen_random_text(1000) for i in range(100)}
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _make_index()
+ t0 = time.perf_counter()
+ await index.add_docs(docs)
+ elapsed = time.perf_counter() - t0
+ print(f" add_docs (100 docs x ~1000 tokens): {elapsed:.4f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_add_docs_large():
+ """Add 100 large docs (~10000 tokens each)."""
+
+ async def run():
+ docs = {f"doc_{i}": _gen_random_text(10000) for i in range(100)}
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _make_index()
+ t0 = time.perf_counter()
+ await index.add_docs(docs)
+ elapsed = time.perf_counter() - t0
+ print(f" add_docs (100 docs x ~10000 tokens): {elapsed:.4f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_retrieve_short_query():
+ """Retrieve with 1-word query."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _setup_index_for_retrieve()
+ query = _gen_random_query(1)
+ t0 = time.perf_counter()
+ await index.retrieve(query, limit=10)
+ elapsed = time.perf_counter() - t0
+ print(f" retrieve (1-word query, 100 docs): {elapsed:.6f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_retrieve_medium_query():
+ """Retrieve with 5-word query."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _setup_index_for_retrieve()
+ query = _gen_random_query(5)
+ t0 = time.perf_counter()
+ await index.retrieve(query, limit=10)
+ elapsed = time.perf_counter() - t0
+ print(f" retrieve (5-word query, 100 docs): {elapsed:.6f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_retrieve_long_query():
+ """Retrieve with 20-word query."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _setup_index_for_retrieve()
+ query = _gen_random_query(20)
+ t0 = time.perf_counter()
+ await index.retrieve(query, limit=10)
+ elapsed = time.perf_counter() - t0
+ print(f" retrieve (20-word query, 100 docs): {elapsed:.6f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+def test_retrieve_very_long_query():
+ """Retrieve with 100-word query."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ index = await _setup_index_for_retrieve()
+ query = _gen_random_query(100)
+ t0 = time.perf_counter()
+ await index.retrieve(query, limit=10)
+ elapsed = time.perf_counter() - t0
+ print(f" retrieve (100-word query, 100 docs): {elapsed:.6f}s")
+ await index.close()
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ random.seed(42)
+ print("=== BM25Index Performance Tests ===\n")
+
+ print("[add_docs]")
+ test_add_docs_small()
+ test_add_docs_medium()
+ test_add_docs_large()
+
+ print("\n[retrieve]")
+ test_retrieve_short_query()
+ test_retrieve_medium_query()
+ test_retrieve_long_query()
+ test_retrieve_very_long_query()
+
+ print("\nDone.")
diff --git a/tests4/unittest/test_bm25_lite.py b/tests4/unittest/test_bm25_lite.py
new file mode 100644
index 00000000..75865797
--- /dev/null
+++ b/tests4/unittest/test_bm25_lite.py
@@ -0,0 +1,586 @@
+"""Tests for BM25Index search engine."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+import warnings
+
+from reme4.components.keyword_index import BM25Index
+from reme4.components.tokenizer import RegexTokenizer
+
+# Filter jieba/pkg_resources deprecation warnings
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+async def create_bm25(k1: float = 1.5, b: float = 0.75) -> BM25Index:
+ """Create and start a BM25Index in cwd with a non-filtering tokenizer.
+
+ The non-filtering tokenizer keeps short test texts (e.g. "hello world") visible,
+ since several common test words ("hello", "我", "的") are in the default stopwords.
+ """
+ bm25 = BM25Index(k1=k1, b=b)
+ # Replace the unresolved Dependency placeholder with a real tokenizer instance.
+ tokenizer = RegexTokenizer(filter_stopwords=False)
+ bm25.tokenizer = tokenizer
+ bm25._owned.append(tokenizer)
+ await bm25.start()
+ return bm25
+
+
+def test_basic_init():
+ """Test BM25Index initialization."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = BM25Index()
+ assert bm25.k1 == 1.5
+ assert bm25.b == 0.75
+ assert bm25.vocab == {}
+ assert bm25.inverted_index == {}
+ assert bm25.doc_meta == {}
+ assert bm25.n_docs == 0
+ assert bm25.avg_len == 0.0
+ print("✓ test_basic_init passed")
+
+ asyncio.run(run())
+
+
+def test_start_with_tokenizer():
+ """Test BM25Index starts and initializes tokenizer."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+ assert bm25.tokenizer is not None
+ assert bm25.is_started
+
+ await bm25.close()
+ assert not bm25.is_started
+ print("✓ test_start_with_tokenizer passed")
+
+ asyncio.run(run())
+
+
+def test_add_single_doc():
+ """Test adding a single document."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs({"doc1": "hello world"})
+
+ assert bm25.n_docs == 1
+ assert bm25.total_len > 0
+ assert "doc1" in bm25.doc_meta
+
+ await bm25.close()
+ print("✓ test_add_single_doc passed")
+
+ asyncio.run(run())
+
+
+def test_add_multiple_docs():
+ """Test adding multiple documents."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ "doc3": "world python",
+ }
+ await bm25.add_docs(docs)
+
+ assert bm25.n_docs == 3
+ assert len(bm25.vocab) > 0
+ assert len(bm25.inverted_index) > 0
+
+ await bm25.close()
+ print("✓ test_add_multiple_docs passed")
+
+ asyncio.run(run())
+
+
+def test_retrieve_basic():
+ """Test basic retrieval functionality."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "python programming language",
+ "doc2": "java programming language",
+ "doc3": "python data analysis",
+ }
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("python", limit=3)
+ assert len(results) <= 3
+ assert "doc1" in results or "doc3" in results
+
+ await bm25.close()
+ print("✓ test_retrieve_basic passed")
+
+ asyncio.run(run())
+
+
+def test_retrieve_with_limit():
+ """Test retrieval with result limit."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {f"doc{i}": f"python programming {i}" for i in range(10)}
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("python", limit=3)
+ assert len(results) == 3
+
+ results = await bm25.retrieve("python", limit=5)
+ assert len(results) == 5
+
+ await bm25.close()
+ print("✓ test_retrieve_with_limit passed")
+
+ asyncio.run(run())
+
+
+def test_retrieve_empty_query():
+ """Test retrieval with empty or unknown query."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {"doc1": "hello world"}
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("", limit=3)
+ assert results == {}
+
+ results = await bm25.retrieve("unknownxyz", limit=3)
+ assert results == {}
+
+ await bm25.close()
+ print("✓ test_retrieve_empty_query passed")
+
+ asyncio.run(run())
+
+
+def test_retrieve_empty_index():
+ """Test retrieval from empty index."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ results = await bm25.retrieve("python", limit=3)
+ assert results == {}
+
+ await bm25.close()
+ print("✓ test_retrieve_empty_index passed")
+
+ asyncio.run(run())
+
+
+def test_update_doc():
+ """Test updating an existing document."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs({"doc1": "hello world python"})
+ old_len = bm25.total_len
+
+ await bm25.add_docs({"doc1": "java"})
+ assert bm25.n_docs == 1
+ assert bm25.total_len != old_len
+
+ results = await bm25.retrieve("java", limit=1)
+ assert "doc1" in results
+
+ await bm25.close()
+ print("✓ test_update_doc passed")
+
+ asyncio.run(run())
+
+
+def test_remove_doc():
+ """Test removing a document."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ }
+ await bm25.add_docs(docs)
+ assert bm25.n_docs == 2
+
+ bm25._remove_doc("doc1")
+ assert bm25.n_docs == 1
+ assert "doc1" not in bm25.doc_meta
+
+ results = await bm25.retrieve("hello", limit=2)
+ assert "doc1" not in results
+ assert "doc2" in results
+
+ await bm25.close()
+ print("✓ test_remove_doc passed")
+
+ asyncio.run(run())
+
+
+def test_remove_nonexistent_doc():
+ """Test removing a nonexistent document (should be no-op)."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs({"doc1": "hello world"})
+ bm25._remove_doc("nonexistent")
+ assert bm25.n_docs == 1
+
+ await bm25.close()
+ print("✓ test_remove_nonexistent_doc passed")
+
+ asyncio.run(run())
+
+
+def test_clear():
+ """Test clearing the index."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs(
+ {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ },
+ )
+ assert bm25.n_docs == 2
+
+ await bm25.clear()
+ assert bm25.n_docs == 0
+ assert bm25.vocab == {}
+ assert bm25.inverted_index == {}
+ assert bm25.doc_meta == {}
+ assert bm25.total_len == 0
+ assert bm25._idf_cache == {}
+
+ await bm25.close()
+ print("✓ test_clear passed")
+
+ asyncio.run(run())
+
+
+def test_optimize_index():
+ """Test optimize_index functionality to compact vocab."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs({"doc1": "hello world"})
+ bm25._remove_doc("doc1")
+
+ assert bm25.n_docs == 0
+ assert len(bm25.vocab) > 0
+
+ await bm25.optimize_index()
+ assert bm25.vocab == {}
+ assert bm25.inverted_index == {}
+
+ await bm25.close()
+ print("✓ test_optimize_index passed")
+
+ asyncio.run(run())
+
+
+def test_optimize_index_with_docs():
+ """Test optimize_index with remaining documents."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs(
+ {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ },
+ )
+
+ old_vocab = bm25.vocab.copy()
+ bm25._remove_doc("doc1")
+
+ await bm25.optimize_index()
+
+ assert bm25.n_docs == 1
+ assert "doc2" in bm25.doc_meta
+ assert len(bm25.vocab) < len(old_vocab)
+
+ results = await bm25.retrieve("hello", limit=1)
+ assert "doc2" in results
+
+ await bm25.close()
+ print("✓ test_optimize_index_with_docs passed")
+
+ asyncio.run(run())
+
+
+def test_persistence():
+ """Test dump and load persistence."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+ docs = {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ "doc3": "programming language",
+ }
+ await bm25.add_docs(docs)
+
+ old_vocab = bm25.vocab.copy()
+ old_doc_meta = {k: dict(v) for k, v in bm25.doc_meta.items()}
+
+ await bm25.dump()
+ await bm25.close()
+
+ bm25_new = await create_bm25()
+
+ assert bm25_new.vocab == old_vocab
+ assert bm25_new.n_docs == 3
+ for doc_id in old_doc_meta:
+ assert doc_id in bm25_new.doc_meta
+
+ results = await bm25_new.retrieve("hello", limit=2)
+ assert "doc1" in results or "doc2" in results
+
+ await bm25_new.close()
+ print("✓ test_persistence passed")
+
+ asyncio.run(run())
+
+
+def test_custom_params():
+ """Test custom k1 and b parameters."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25(k1=2.0, b=0.5)
+
+ assert bm25.k1 == 2.0
+ assert bm25.b == 0.5
+
+ await bm25.add_docs({"doc1": "test document"})
+ results = await bm25.retrieve("test", limit=1)
+ assert "doc1" in results
+
+ await bm25.close()
+ print("✓ test_custom_params passed")
+
+ asyncio.run(run())
+
+
+def test_chinese_text():
+ """Test with Chinese text."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "我爱北京天安门",
+ "doc2": "北京是中国的首都",
+ "doc3": "上海的天气很好",
+ }
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("北", limit=2)
+ assert len(results) <= 2
+ assert "doc1" in results or "doc2" in results
+
+ await bm25.close()
+ print("✓ test_chinese_text passed")
+
+ asyncio.run(run())
+
+
+def test_mixed_chinese_english():
+ """Test with mixed Chinese and English text."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "Python 是一种编程语言",
+ "doc2": "Java 编程语言",
+ "doc3": "Python 数据分析",
+ }
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("Python", limit=3)
+ assert len(results) > 0
+
+ results = await bm25.retrieve("编", limit=2)
+ assert len(results) > 0
+
+ await bm25.close()
+ print("✓ test_mixed_chinese_english passed")
+
+ asyncio.run(run())
+
+
+def test_idf_cache():
+ """Test IDF cache functionality."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs(
+ {
+ "doc1": "hello world",
+ "doc2": "hello python",
+ },
+ )
+
+ token = "hello"
+ if token in bm25.vocab:
+ tid = bm25.vocab[token]
+ idf1 = bm25._get_idf(tid)
+ assert tid in bm25._idf_cache
+ idf2 = bm25._get_idf(tid)
+ assert idf1 == idf2
+
+ await bm25.close()
+ print("✓ test_idf_cache passed")
+
+ asyncio.run(run())
+
+
+def test_avg_len():
+ """Test average document length calculation."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ assert bm25.avg_len == 0.0
+
+ await bm25.add_docs({"doc1": "hello world python"})
+ assert bm25.avg_len > 0
+
+ await bm25.add_docs({"doc2": "test"})
+ new_avg = bm25.avg_len
+ assert new_avg > 0
+
+ await bm25.close()
+ print("✓ test_avg_len passed")
+
+ asyncio.run(run())
+
+
+def test_score_ordering():
+ """Test that results are ordered by score descending."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ docs = {
+ "doc1": "python python python",
+ "doc2": "python python",
+ "doc3": "python",
+ }
+ await bm25.add_docs(docs)
+
+ results = await bm25.retrieve("python", limit=3)
+ scores = list(results.values())
+
+ for i in range(len(scores) - 1):
+ assert scores[i] >= scores[i + 1]
+
+ await bm25.close()
+ print("✓ test_score_ordering passed")
+
+ asyncio.run(run())
+
+
+def test_empty_doc():
+ """Test adding empty document."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ bm25 = await create_bm25()
+
+ await bm25.add_docs({"doc1": ""})
+ assert bm25.n_docs == 0
+
+ await bm25.add_docs({"doc2": " "})
+ assert bm25.n_docs == 0
+
+ await bm25.close()
+ print("✓ test_empty_doc passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== BM25Index Tests ===")
+ test_basic_init()
+ test_start_with_tokenizer()
+ test_add_single_doc()
+ test_add_multiple_docs()
+ test_retrieve_basic()
+ test_retrieve_with_limit()
+ test_retrieve_empty_query()
+ test_retrieve_empty_index()
+ test_update_doc()
+ test_remove_doc()
+ test_remove_nonexistent_doc()
+ test_clear()
+ test_optimize_index()
+ test_optimize_index_with_docs()
+ test_persistence()
+ test_custom_params()
+ test_chinese_text()
+ test_mixed_chinese_english()
+ test_idf_cache()
+ test_avg_len()
+ test_score_ordering()
+ test_empty_doc()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_common_steps.py b/tests4/unittest/test_common_steps.py
new file mode 100644
index 00000000..ad7d5f5a
--- /dev/null
+++ b/tests4/unittest/test_common_steps.py
@@ -0,0 +1,290 @@
+"""End-to-end tests for reme4 common steps: spawn `reme4 start`, drive via HTTP,
+verify responses, then shut down. Each test uses an isolated cwd so the working_dir
+(.reme by default) does not collide.
+"""
+
+import asyncio
+import os
+import tempfile
+import warnings
+
+from reme4 import __version__ as REME_VERSION
+from reme4.utils import call_action, call_and_check, mock_reme_server
+
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
+
+
+class _temp_chdir:
+ """chdir to path for the duration of the block; restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self._old = None
+
+ def __enter__(self):
+ self._old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self._old)
+
+
+def _run(coro):
+ """Run an async coroutine on a fresh isolated event loop."""
+ asyncio.run(coro)
+
+
+# ---------------------------------------------------------------------------
+# Individual job tests
+# ---------------------------------------------------------------------------
+
+
+def test_version_job():
+ """version job should return the package version string."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "version",
+ host=host,
+ port=port,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and r.get("answer") == REME_VERSION
+ and r.get("metadata", {}).get("version") == REME_VERSION
+ ),
+ )
+ print("✓ test_version_job passed")
+
+ _run(run())
+
+
+def test_help_job():
+ """help job should list jobs except itself."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ result = await call_and_check(
+ "help",
+ host=host,
+ port=port,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and isinstance(r.get("answer"), str)
+ and r.get("metadata", {}).get("job_count", 0) > 0
+ and "help" not in r["answer"]
+ ),
+ )
+ # Spot-check that a couple of known jobs appear in the listing.
+ answer = result["answer"]
+ for expected_job in ("version", "health_check", "search"):
+ if expected_job not in answer:
+ raise AssertionError(f"help output missing job {expected_job!r}: {answer!r}")
+ print("✓ test_help_job passed")
+
+ _run(run())
+
+
+def test_health_check_job():
+ """health_check job should return a structured health snapshot."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ result = await call_and_check(
+ "health_check",
+ host=host,
+ port=port,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and isinstance(r.get("metadata"), dict)
+ and isinstance(r["metadata"].get("health"), dict)
+ and r["metadata"]["health"].get("version") == REME_VERSION
+ and isinstance(r["metadata"]["health"].get("components"), dict)
+ ),
+ )
+ # Validate that each expected component type is in the snapshot.
+ components = result["metadata"]["health"]["components"]
+ for ctype in (
+ "embedding_model",
+ "file_graph",
+ "file_store",
+ "file_watcher",
+ "keyword_index",
+ ):
+ if ctype not in components:
+ raise AssertionError(f"health snapshot missing component {ctype!r}: {components!r}")
+ print("✓ test_health_check_job passed")
+
+ _run(run())
+
+
+def test_search_job_empty_store():
+ """search on an empty store should return successfully with zero results."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "search",
+ host=host,
+ port=port,
+ query="hello world",
+ limit=5,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and isinstance(r.get("metadata"), dict)
+ and isinstance(r["metadata"].get("counts"), dict)
+ and r["metadata"]["counts"].get("returned", -1) == 0
+ ),
+ )
+ print("✓ test_search_job_empty_store passed")
+
+ _run(run())
+
+
+def test_search_job_missing_query():
+ """search without a query should surface the assertion error in `answer`."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ result = await call_action("search", host=host, port=port, query="")
+ if not isinstance(result, dict):
+ raise AssertionError(f"expected dict response, got {result!r}")
+ if "query" not in str(result.get("answer", "")).lower():
+ raise AssertionError(f"expected query-related error in answer, got {result!r}")
+ print("✓ test_search_job_missing_query passed")
+
+ _run(run())
+
+
+def test_reindex_job():
+ """reindex job should wipe the file store and rebuild from tracked files."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "reindex",
+ host=host,
+ port=port,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and isinstance(r.get("metadata", {}).get("counts"), dict)
+ and "added" in r["metadata"]["counts"]
+ ),
+ )
+ print("✓ test_reindex_job passed")
+
+ _run(run())
+
+
+def test_demo_job():
+ """demo job should echo back the normalized query and adjusted min_score."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "demo",
+ host=host,
+ port=port,
+ query=" Hello World ",
+ min_score=0.8,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and "hello world" in str(r.get("answer", ""))
+ and abs(r.get("metadata", {}).get("adjusted_min_score", 0) - 0.72) < 1e-6
+ ),
+ )
+ print("✓ test_demo_job passed")
+
+ _run(run())
+
+
+# ---------------------------------------------------------------------------
+# Aggregate test: reuse one server instance for all jobs (faster).
+# ---------------------------------------------------------------------------
+
+
+def test_all_jobs_one_server():
+ """Run every common job against a single shared server for efficiency."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ async with mock_reme_server() as (host, port):
+ # version
+ await call_and_check(
+ "version",
+ host=host,
+ port=port,
+ validator=lambda r: isinstance(r, dict) and r.get("answer") == REME_VERSION,
+ )
+ # help
+ await call_and_check(
+ "help",
+ host=host,
+ port=port,
+ validator=lambda r: isinstance(r, dict) and r.get("metadata", {}).get("job_count", 0) > 0,
+ )
+ # health_check
+ await call_and_check(
+ "health_check",
+ host=host,
+ port=port,
+ validator=lambda r: isinstance(r, dict)
+ and isinstance(
+ r.get("metadata", {}).get("health"),
+ dict,
+ ),
+ )
+ # search (empty store)
+ await call_and_check(
+ "search",
+ host=host,
+ port=port,
+ query="anything",
+ validator=lambda r: isinstance(r, dict) and r.get("success") is True,
+ )
+ # reindex
+ await call_and_check(
+ "reindex",
+ host=host,
+ port=port,
+ validator=lambda r: isinstance(r, dict) and isinstance(r.get("metadata", {}).get("counts"), dict),
+ )
+ # demo
+ await call_and_check(
+ "demo",
+ host=host,
+ port=port,
+ query="Foo",
+ validator=lambda r: isinstance(r, dict) and "foo" in str(r.get("answer", "")),
+ )
+ print("✓ test_all_jobs_one_server passed")
+
+ _run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== reme4 common steps E2E tests ===")
+ test_version_job()
+ test_help_job()
+ test_health_check_job()
+ test_search_job_empty_store()
+ test_search_job_missing_query()
+ test_reindex_job()
+ test_demo_job()
+ test_all_jobs_one_server()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_default_file_parser.py b/tests4/unittest/test_default_file_parser.py
new file mode 100644
index 00000000..5730078b
--- /dev/null
+++ b/tests4/unittest/test_default_file_parser.py
@@ -0,0 +1,387 @@
+"""Tests for DefaultFileParser."""
+
+import asyncio
+import os
+import tempfile
+
+from reme4.components.file_parser import DefaultFileParser
+
+
+# Add parent path for import
+
+
+def test_parse_empty_file():
+ """Test parsing an empty file."""
+
+ async def run():
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser()
+ file_node, chunks = await parser.parse(temp_path)
+ assert file_node.path == temp_path
+ assert len(chunks) == 0
+ print("✓ test_parse_empty_file passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_small_file():
+ """Test parsing a file smaller than chunk size."""
+
+ async def run():
+ content = "Hello World\nThis is a test\nLine 3"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser(chunk_byte_size=10000)
+ _, chunks = await parser.parse(temp_path)
+ assert len(chunks) == 1
+ assert chunks[0].start_line == 1
+ assert chunks[0].end_line == 3
+ assert chunks[0].text == content
+ print("✓ test_parse_small_file passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_multiline_file():
+ """Test parsing a file with multiple lines."""
+
+ async def run():
+ lines = ["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]
+ content = "\n".join(lines)
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser(chunk_byte_size=10000)
+ _, chunks = await parser.parse(temp_path)
+ assert len(chunks) == 1
+ assert chunks[0].start_line == 1
+ assert chunks[0].end_line == 5
+ print("✓ test_parse_multiline_file passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_chunked_file():
+ """Test parsing a file that requires multiple chunks."""
+
+ async def run():
+ # Create content larger than chunk size
+ lines = ["A" * 100 for _ in range(200)] # ~20200 bytes
+ content = "\n".join(lines)
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser(chunk_byte_size=5000, overlap_byte_size=100)
+ _, chunks = await parser.parse(temp_path)
+ assert len(chunks) > 1, f"Expected multiple chunks, got {len(chunks)}"
+ # Verify overlap by checking that consecutive chunks share some content
+ print(f" Created {len(chunks)} chunks")
+ print("✓ test_parse_chunked_file passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_with_custom_encoding():
+ """Test parsing a file with different encodings."""
+
+ async def run():
+ content = "你好世界\n测试内容"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt", encoding="utf-8") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser(encoding="utf-8")
+ _, chunks = await parser.parse(temp_path)
+ assert len(chunks) >= 1
+ assert "你好世界" in chunks[0].text
+ print("✓ test_parse_with_custom_encoding passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_file_node_properties():
+ """Test FileNode has correct properties."""
+
+ async def run():
+ content = "test content"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser()
+ file_node, _ = await parser.parse(temp_path)
+ assert hasattr(file_node, "path")
+ assert hasattr(file_node, "st_mtime")
+ assert file_node.st_mtime > 0
+ print("✓ test_file_node_properties passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_file_chunk_properties():
+ """Test FileChunk has correct properties."""
+
+ async def run():
+ content = "test content for chunk"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser()
+ _, chunks = await parser.parse(temp_path)
+ chunk = chunks[0]
+ assert hasattr(chunk, "path")
+ assert hasattr(chunk, "start_line")
+ assert hasattr(chunk, "end_line")
+ assert hasattr(chunk, "text")
+ assert hasattr(chunk, "id")
+ assert chunk.start_line >= 1
+ assert chunk.end_line >= chunk.start_line
+ print("✓ test_file_chunk_properties passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_links_bare():
+ """Bare wikilink: [[target]]."""
+ links = DefaultFileParser.parse_links("see [[note]]", "src.md")
+ assert len(links) == 1
+ link = links[0]
+ assert link.source_path == "src.md"
+ assert link.target_path == "note"
+ assert link.target_anchor is None
+ assert link.predicate is None
+ print("✓ test_parse_links_bare passed")
+
+
+def test_parse_links_with_anchor():
+ """Wikilink with anchor: [[target#anchor]]."""
+ links = DefaultFileParser.parse_links("see [[note#section A]]", "src.md")
+ assert len(links) == 1
+ assert links[0].target_path == "note"
+ assert links[0].target_anchor == "section A"
+ assert links[0].predicate is None
+ print("✓ test_parse_links_with_anchor passed")
+
+
+def test_parse_links_alias_dropped():
+ """Alias after '|' is consumed but not captured as anchor."""
+ links = DefaultFileParser.parse_links("see [[note|display text]]", "src.md")
+ assert len(links) == 1
+ assert links[0].target_path == "note"
+ assert links[0].target_anchor is None
+ print("✓ test_parse_links_alias_dropped passed")
+
+
+def test_parse_links_anchor_and_alias():
+ """[[target#anchor|alias]] — anchor captured, alias dropped."""
+ links = DefaultFileParser.parse_links("see [[note#sec|disp]]", "src.md")
+ assert len(links) == 1
+ assert links[0].target_path == "note"
+ assert links[0].target_anchor == "sec"
+ print("✓ test_parse_links_anchor_and_alias passed")
+
+
+def test_parse_links_predicate_simple():
+ """Dataview inline: predicate:: [[target]]."""
+ links = DefaultFileParser.parse_links("author:: [[Alice]]", "src.md")
+ assert len(links) == 1
+ assert links[0].predicate == "author"
+ assert links[0].target_path == "Alice"
+ assert links[0].target_anchor is None
+ print("✓ test_parse_links_predicate_simple passed")
+
+
+def test_parse_links_predicate_bracketed():
+ """Dataview inline-bracket: [predicate:: [[target]]]."""
+ links = DefaultFileParser.parse_links("text [author:: [[Alice]]] more", "src.md")
+ assert len(links) == 1
+ assert links[0].predicate == "author"
+ assert links[0].target_path == "Alice"
+ print("✓ test_parse_links_predicate_bracketed passed")
+
+
+def test_parse_links_predicate_bracketed_with_anchor():
+ """[predicate:: [[target_path#target_anchor]]] — combined form."""
+ links = DefaultFileParser.parse_links(
+ "[predicate:: [[target_path#target_anchor]]]",
+ "src.md",
+ )
+ assert len(links) == 1
+ link = links[0]
+ assert link.source_path == "src.md"
+ assert link.predicate == "predicate"
+ assert link.target_path == "target_path"
+ assert link.target_anchor == "target_anchor"
+ print("✓ test_parse_links_predicate_bracketed_with_anchor passed")
+
+
+def test_parse_links_predicate_sticks_to_first():
+ """Predicate attaches only to the immediately following wikilink."""
+ links = DefaultFileParser.parse_links("pred:: [[a]] and bare [[b]]", "src.md")
+ assert len(links) == 2
+ assert links[0].predicate == "pred" and links[0].target_path == "a"
+ assert links[1].predicate is None and links[1].target_path == "b"
+ print("✓ test_parse_links_predicate_sticks_to_first passed")
+
+
+def test_parse_links_multiple_on_one_line():
+ """Multiple bare wikilinks on the same line are all captured."""
+ links = DefaultFileParser.parse_links("see [[x]] and [[y#h]]", "src.md")
+ assert [(link.target_path, link.target_anchor) for link in links] == [
+ ("x", None),
+ ("y", "h"),
+ ]
+ print("✓ test_parse_links_multiple_on_one_line passed")
+
+
+def test_parse_links_no_match():
+ """Strings without [[]] yield no links, even if '::' appears."""
+ assert len(DefaultFileParser.parse_links("no link here :: foo", "src.md")) == 0
+ assert len(DefaultFileParser.parse_links("plain text without brackets", "src.md")) == 0
+ assert len(DefaultFileParser.parse_links("", "src.md")) == 0
+ print("✓ test_parse_links_no_match passed")
+
+
+def test_parse_links_predicate_with_dash_and_digits():
+ """Predicate identifier accepts letters, digits, underscore, dash."""
+ links = DefaultFileParser.parse_links("see-also-2:: [[target]]", "src.md")
+ assert len(links) == 1
+ assert links[0].predicate == "see-also-2"
+ assert links[0].target_path == "target"
+ print("✓ test_parse_links_predicate_with_dash_and_digits passed")
+
+
+def test_parse_links_in_file():
+ """Integration: parse() populates FileNode.links from file content."""
+
+ async def run():
+ content = (
+ "---\n"
+ "title: demo\n"
+ "---\n"
+ "\n"
+ "Intro paragraph with [[alpha]] and [[beta#h2]].\n"
+ "author:: [[Alice]]\n"
+ "[ref:: [[paper#chapter 1]]]\n"
+ )
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ parser = DefaultFileParser()
+ file_node, _ = await parser.parse(temp_path)
+ triples = {(link.predicate, link.target_path, link.target_anchor) for link in file_node.links}
+ assert (None, "alpha", None) in triples
+ assert (None, "beta", "h2") in triples
+ assert ("author", "Alice", None) in triples
+ assert ("ref", "paper", "chapter 1") in triples
+ assert all(link.source_path == file_node.path for link in file_node.links)
+ print("✓ test_parse_links_in_file passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+def test_parse_links_empty_when_no_content():
+ """Empty file and front-matter-only file both yield no links."""
+
+ async def run():
+ # Empty file
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ empty_path = f.name
+ # Front-matter-only file
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".md") as f:
+ f.write("---\ntitle: x\n---\n")
+ fm_only_path = f.name
+
+ try:
+ parser = DefaultFileParser()
+ node1, _ = await parser.parse(empty_path)
+ node2, _ = await parser.parse(fm_only_path)
+ assert node1.links == []
+ assert node2.links == []
+ print("✓ test_parse_links_empty_when_no_content passed")
+ finally:
+ os.unlink(empty_path)
+ os.unlink(fm_only_path)
+
+ asyncio.run(run())
+
+
+def test_min_chunk_and_overlap_size():
+ """Test that minimum chunk and overlap sizes are enforced."""
+
+ async def run():
+ # These values should be clamped to minimums
+ parser = DefaultFileParser(chunk_byte_size=1, overlap_byte_size=0)
+ assert parser.chunk_byte_size == 100 # minimum
+ assert parser.overlap_byte_size == 4 # minimum
+
+ content = "test"
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f:
+ f.write(content)
+ temp_path = f.name
+
+ try:
+ _, chunks = await parser.parse(temp_path)
+ assert len(chunks) == 1
+ print("✓ test_min_chunk_and_overlap_size passed")
+ finally:
+ os.unlink(temp_path)
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ test_parse_empty_file()
+ test_parse_small_file()
+ test_parse_multiline_file()
+ test_parse_chunked_file()
+ test_parse_with_custom_encoding()
+ test_file_node_properties()
+ test_file_chunk_properties()
+ test_parse_links_bare()
+ test_parse_links_with_anchor()
+ test_parse_links_alias_dropped()
+ test_parse_links_anchor_and_alias()
+ test_parse_links_predicate_simple()
+ test_parse_links_predicate_bracketed()
+ test_parse_links_predicate_bracketed_with_anchor()
+ test_parse_links_predicate_sticks_to_first()
+ test_parse_links_multiple_on_one_line()
+ test_parse_links_no_match()
+ test_parse_links_predicate_with_dash_and_digits()
+ test_parse_links_in_file()
+ test_parse_links_empty_when_no_content()
+ test_min_chunk_and_overlap_size()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_file_graph.py b/tests4/unittest/test_file_graph.py
new file mode 100644
index 00000000..1dc3f6f5
--- /dev/null
+++ b/tests4/unittest/test_file_graph.py
@@ -0,0 +1,333 @@
+"""Tests for FileGraph backends (LocalFileGraph + NxFileGraph)."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+
+import pytest
+
+from reme4.components.file_graph import LocalFileGraph, NxFileGraph
+from reme4.schema import FileLink, FileNode
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+def make_node(path: str, links: list[tuple[str, str | None]] | None = None) -> FileNode:
+ """Build a FileNode with the given outgoing (target_path, target_anchor) pairs."""
+ return FileNode(
+ path=path,
+ st_mtime=1.0,
+ links=[FileLink(source_path=path, target_path=t, target_anchor=a) for t, a in (links or [])],
+ )
+
+
+# Both backends should satisfy the same BaseFileGraph contract.
+BACKENDS = [LocalFileGraph, NxFileGraph]
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_upsert_and_get_nodes(backend_cls):
+ """upsert_nodes stores nodes; get_nodes returns them by path or all."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ n1 = make_node("a.md", [("b.md", None)])
+ n2 = make_node("b.md")
+ await graph.upsert_nodes([n1, n2])
+
+ got_all = await graph.get_nodes()
+ assert {n.path for n in got_all} == {"a.md", "b.md"}
+
+ got_one = await graph.get_nodes(["a.md"])
+ assert len(got_one) == 1
+ assert got_one[0].path == "a.md"
+
+ got_missing = await graph.get_nodes(["nope.md"])
+ assert got_missing == []
+
+ await graph.close()
+ print(f"✓ test_upsert_and_get_nodes[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_upsert_replaces_old_links(backend_cls):
+ """Re-upserting a node with new links replaces the old outgoing edges."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
+ assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"b.md"}
+
+ # Replace a's link target from b → c
+ await graph.upsert_nodes([make_node("a.md", [("c.md", None)])])
+ assert {lnk.target_path for lnk in await graph.get_outlinks("a.md")} == {"c.md"}
+ # b should no longer have a as an inlink
+ assert await graph.get_inlinks("b.md") == []
+ assert {lnk.source_path for lnk in await graph.get_inlinks("c.md")} == {"a.md"}
+
+ await graph.close()
+ print(f"✓ test_upsert_replaces_old_links[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_outlinks_skip_virtual_targets(backend_cls):
+ """get_outlinks only returns links pointing to real (existing) nodes."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ # a links to b (real) and ghost (virtual)
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("ghost.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+
+ outs = await graph.get_outlinks("a.md")
+ targets = {lnk.target_path for lnk in outs}
+ assert targets == {"b.md"}
+
+ await graph.close()
+ print(f"✓ test_outlinks_skip_virtual_targets[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_inlinks_promotion_after_upsert(backend_cls):
+ """Edges to virtual targets become real inlinks once the target is upserted."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ # b doesn't exist yet — link is pending
+ await graph.upsert_nodes([make_node("a.md", [("b.md", None)])])
+ assert await graph.get_inlinks("b.md") == [] # b not real yet
+
+ # Now create b — pending edge promotes
+ await graph.upsert_nodes([make_node("b.md")])
+ inlinks = await graph.get_inlinks("b.md")
+ assert {lnk.source_path for lnk in inlinks} == {"a.md"}
+
+ await graph.close()
+ print(f"✓ test_inlinks_promotion_after_upsert[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_delete_node_demotes_inbound(backend_cls):
+ """Deleting a node makes it virtual; sources still hold the link, but get_inlinks([deleted]) is []."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
+
+ await graph.delete_nodes(["b.md"])
+ # b is no longer a real node
+ assert await graph.get_nodes(["b.md"]) == []
+ # inlinks query for a non-real node returns []
+ assert await graph.get_inlinks("b.md") == []
+ # a's outlink to b is hidden because b is virtual
+ assert await graph.get_outlinks("a.md") == []
+
+ # Re-upsert b — pending should re-promote
+ await graph.upsert_nodes([make_node("b.md")])
+ assert {lnk.source_path for lnk in await graph.get_inlinks("b.md")} == {"a.md"}
+
+ await graph.close()
+ print(f"✓ test_delete_node_demotes_inbound[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_delete_outgoing_links_cleared(backend_cls):
+ """Deleting a source node drops its outgoing edges (no inlink left on its targets)."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await graph.delete_nodes(["a.md"])
+
+ assert await graph.get_inlinks("b.md") == []
+
+ await graph.close()
+ print(f"✓ test_delete_outgoing_links_cleared[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_clear(backend_cls):
+ """clear() removes all nodes and edges."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await graph.clear()
+ assert await graph.get_nodes() == []
+
+ await graph.close()
+ print(f"✓ test_clear[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_rebuild_links_idempotent(backend_cls):
+ """rebuild_links produces the same outlink/inlink view as the original upserts."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("c.md", "h")]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
+
+ before_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
+ before_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
+
+ await graph.rebuild_links()
+
+ after_out = sorted((lnk.target_path, lnk.target_anchor) for lnk in await graph.get_outlinks("a.md"))
+ after_in = sorted(lnk.source_path for lnk in await graph.get_inlinks("b.md"))
+
+ assert before_out == after_out
+ assert before_in == after_in
+
+ await graph.close()
+ print(f"✓ test_rebuild_links_idempotent[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_persistence_roundtrip(backend_cls):
+ """close() dumps; a fresh instance loads the same nodes from disk."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ g1 = backend_cls()
+ await g1.start()
+ await g1.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ await g1.close() # triggers dump
+
+ g2 = backend_cls()
+ await g2.start() # triggers load
+ paths = sorted(n.path for n in await g2.get_nodes())
+ assert paths == ["a.md", "b.md"]
+ # Inlink relationship should also be reconstructable.
+ assert {lnk.source_path for lnk in await g2.get_inlinks("b.md")} == {"a.md"}
+ await g2.close()
+ print(f"✓ test_persistence_roundtrip[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+@pytest.mark.parametrize("backend_cls", BACKENDS)
+def test_get_nodes_empty_inputs(backend_cls):
+ """get_nodes([]) returns []; get_nodes(None) returns all."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ graph = backend_cls()
+ await graph.start()
+
+ await graph.upsert_nodes([make_node("a.md")])
+ assert await graph.get_nodes([]) == []
+ assert len(await graph.get_nodes(None)) == 1
+
+ await graph.close()
+ print(f"✓ test_get_nodes_empty_inputs[{backend_cls.__name__}] passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== FileGraph Tests ===")
+ for backend in BACKENDS:
+ test_upsert_and_get_nodes(backend)
+ test_upsert_replaces_old_links(backend)
+ test_outlinks_skip_virtual_targets(backend)
+ test_inlinks_promotion_after_upsert(backend)
+ test_delete_node_demotes_inbound(backend)
+ test_delete_outgoing_links_cleared(backend)
+ test_clear(backend)
+ test_rebuild_links_idempotent(backend)
+ test_persistence_roundtrip(backend)
+ test_get_nodes_empty_inputs(backend)
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_file_store.py b/tests4/unittest/test_file_store.py
new file mode 100644
index 00000000..e59e305b
--- /dev/null
+++ b/tests4/unittest/test_file_store.py
@@ -0,0 +1,330 @@
+"""Tests for LocalFileStore."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+import warnings
+
+from reme4.components.file_store import LocalFileStore
+from reme4.schema import FileChunk, FileNode
+
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+async def make_store(store_name: str = "test_store", **kwargs) -> LocalFileStore:
+ """Build a started LocalFileStore with embedding disabled (no OpenAI dep)."""
+ store = LocalFileStore(store_name=store_name, embedding_model="", **kwargs)
+ await store.start()
+ return store
+
+
+def make_file(
+ path: str,
+ text: str,
+ chunk_count: int = 1,
+) -> tuple[FileNode, list[FileChunk]]:
+ """Build a (FileNode, [FileChunk]) tuple ready for upsert_file."""
+ chunks = [
+ FileChunk(id=f"{path}::chunk{i}", path=path, text=f"{text} part{i}", start_line=i, end_line=i + 1)
+ for i in range(chunk_count)
+ ]
+ node = FileNode(path=path, st_mtime=1.0, chunk_ids=[c.id for c in chunks])
+ return node, chunks
+
+
+def test_upsert_single_file():
+ """upsert_file with a single (node, chunks) tuple stores chunks and node."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ node, chunks = make_file("a.md", "hello world", chunk_count=2)
+ await store.upsert_file((node, chunks))
+
+ # Chunks landed in memory
+ assert len(store.file_chunks) == 2
+ assert {c.path for c in store.file_chunks.values()} == {"a.md"}
+ # Node landed in graph
+ nodes = await store.file_graph.get_nodes(["a.md"])
+ assert len(nodes) == 1
+ assert sorted(nodes[0].chunk_ids) == sorted([c.id for c in chunks])
+
+ await store.close()
+ print("✓ test_upsert_single_file passed")
+
+ asyncio.run(run())
+
+
+def test_upsert_multiple_files():
+ """upsert_file accepts a list of tuples and indexes them all."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ files = [make_file("a.md", "alpha"), make_file("b.md", "beta")]
+ await store.upsert_file(files)
+
+ assert len(store.file_chunks) == 2
+ paths = {n.path for n in await store.file_graph.get_nodes()}
+ assert paths == {"a.md", "b.md"}
+
+ await store.close()
+ print("✓ test_upsert_multiple_files passed")
+
+ asyncio.run(run())
+
+
+def test_upsert_replaces_old_chunks():
+ """Re-upserting the same path points the node at the new chunk set."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ n1, c1 = make_file("a.md", "v1", chunk_count=2)
+ await store.upsert_file((n1, c1))
+
+ # Different chunks for the same path
+ n2 = FileNode(path="a.md", st_mtime=2.0)
+ c2 = [FileChunk(id="a.md::new", path="a.md", text="v2 only", start_line=0, end_line=1)]
+ n2.chunk_ids = [c.id for c in c2]
+ await store.upsert_file((n2, c2))
+
+ # The node now references the new chunk set, not the old one.
+ nodes = await store.file_graph.get_nodes(["a.md"])
+ assert nodes[0].chunk_ids == ["a.md::new"]
+ assert "a.md::new" in store.file_chunks
+
+ await store.close()
+ print("✓ test_upsert_replaces_old_chunks passed")
+
+ asyncio.run(run())
+
+
+def test_delete_by_path_single():
+ """delete_by_path drops chunks and the node entry."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ await store.upsert_file([make_file("a.md", "alpha"), make_file("b.md", "beta")])
+ await store.delete_by_path("a.md")
+
+ assert all(c.path != "a.md" for c in store.file_chunks.values())
+ assert {n.path for n in await store.file_graph.get_nodes()} == {"b.md"}
+
+ await store.close()
+ print("✓ test_delete_by_path_single passed")
+
+ asyncio.run(run())
+
+
+def test_delete_by_path_list():
+ """delete_by_path accepts a list of paths."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ await store.upsert_file(
+ [
+ make_file("a.md", "alpha"),
+ make_file("b.md", "beta"),
+ make_file("c.md", "gamma"),
+ ],
+ )
+ await store.delete_by_path(["a.md", "b.md"])
+
+ assert {n.path for n in await store.file_graph.get_nodes()} == {"c.md"}
+ assert all(c.path == "c.md" for c in store.file_chunks.values())
+
+ await store.close()
+ print("✓ test_delete_by_path_list passed")
+
+ asyncio.run(run())
+
+
+def test_delete_by_path_missing_is_noop():
+ """Deleting a nonexistent path is a no-op."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ await store.upsert_file(make_file("a.md", "alpha"))
+ before = len(store.file_chunks)
+ await store.delete_by_path("ghost.md")
+ assert len(store.file_chunks) == before
+
+ await store.close()
+ print("✓ test_delete_by_path_missing_is_noop passed")
+
+ asyncio.run(run())
+
+
+def test_clear():
+ """clear() empties chunks and the file graph."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ await store.upsert_file([make_file("a.md", "alpha"), make_file("b.md", "beta")])
+ await store.clear()
+
+ assert store.file_chunks == {}
+ assert await store.file_graph.get_nodes() == []
+
+ await store.close()
+ print("✓ test_clear passed")
+
+ asyncio.run(run())
+
+
+def test_keyword_search():
+ """keyword_search returns matching chunks ranked by BM25 score."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ await store.upsert_file(
+ [
+ make_file("a.md", "python programming language"),
+ make_file("b.md", "java programming language"),
+ make_file("c.md", "python data analysis"),
+ ],
+ )
+
+ results = await store.keyword_search("python", limit=5, search_filter={})
+ paths = {r.path for r in results}
+ assert "a.md" in paths or "c.md" in paths
+ # Each result should carry a keyword score.
+ for r in results:
+ assert r.scores.get("keyword", 0) > 0
+
+ await store.close()
+ print("✓ test_keyword_search passed")
+
+ asyncio.run(run())
+
+
+def test_keyword_search_empty_query():
+ """Empty/whitespace queries return no results."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+ await store.upsert_file(make_file("a.md", "hello"))
+
+ assert await store.keyword_search("", limit=5, search_filter={}) == []
+ assert await store.keyword_search(" ", limit=5, search_filter={}) == []
+
+ await store.close()
+ print("✓ test_keyword_search_empty_query passed")
+
+ asyncio.run(run())
+
+
+def test_vector_search_disabled_returns_empty():
+ """Without an embedding model, vector_search returns []."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+ await store.upsert_file(make_file("a.md", "hello"))
+
+ assert store.embedding_model is None
+ assert await store.vector_search("hello", limit=5, search_filter={}) == []
+
+ await store.close()
+ print("✓ test_vector_search_disabled_returns_empty passed")
+
+ asyncio.run(run())
+
+
+def test_persistence_roundtrip():
+ """close() dumps chunks; a fresh store loads them from disk."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ s1 = await make_store()
+ await s1.upsert_file([make_file("a.md", "alpha"), make_file("b.md", "beta")])
+ await s1.close()
+
+ s2 = await make_store()
+ assert {c.path for c in s2.file_chunks.values()} == {"a.md", "b.md"}
+ # Graph should also be persisted independently via its own dump.
+ assert {n.path for n in await s2.file_graph.get_nodes()} == {"a.md", "b.md"}
+ await s2.close()
+ print("✓ test_persistence_roundtrip passed")
+
+ asyncio.run(run())
+
+
+def test_rebuild_links_delegates_to_graph():
+ """rebuild_links() on the store delegates to the underlying file_graph."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ store = await make_store()
+
+ from reme4.schema import FileLink
+
+ node = FileNode(
+ path="a.md",
+ st_mtime=1.0,
+ links=[FileLink(source_path="a.md", target_path="b.md")],
+ )
+ chunks = [FileChunk(id="a::1", path="a.md", text="x", start_line=0, end_line=1)]
+ node.chunk_ids = [c.id for c in chunks]
+ await store.upsert_file((node, chunks))
+ await store.upsert_file(make_file("b.md", "beta"))
+
+ await store.rebuild_links()
+ inlinks = await store.get_inlinks("b.md")
+ assert {lnk.source_path for lnk in inlinks} == {"a.md"}
+
+ await store.close()
+ print("✓ test_rebuild_links_delegates_to_graph passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== LocalFileStore Tests ===")
+ test_upsert_single_file()
+ test_upsert_multiple_files()
+ test_upsert_replaces_old_chunks()
+ test_delete_by_path_single()
+ test_delete_by_path_list()
+ test_delete_by_path_missing_is_noop()
+ test_clear()
+ test_keyword_search()
+ test_keyword_search_empty_query()
+ test_vector_search_disabled_returns_empty()
+ test_persistence_roundtrip()
+ test_rebuild_links_delegates_to_graph()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_file_watcher.py b/tests4/unittest/test_file_watcher.py
new file mode 100644
index 00000000..f7008567
--- /dev/null
+++ b/tests4/unittest/test_file_watcher.py
@@ -0,0 +1,349 @@
+"""Tests for LiteFileWatcher (excluding the awatch main loop)."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+import warnings
+from pathlib import Path
+
+from watchfiles import Change
+
+from reme4.components.file_parser import DefaultFileParser
+from reme4.components.file_store import LocalFileStore
+from reme4.components.file_watcher import LiteFileWatcher
+
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+async def make_watcher(watch_paths: list[str] | str = "vault", **kwargs) -> LiteFileWatcher:
+ """Build a LiteFileWatcher with real (started) file_store/file_parser, but no background loop.
+
+ We replace the bind() Dependency placeholders with concrete instances and start them
+ manually, so tests can call update_store / on_* directly without the background task.
+ """
+ watcher = LiteFileWatcher(watch_paths=watch_paths, **kwargs)
+ fs = LocalFileStore(store_name="test_store", embedding_model="")
+ parser = DefaultFileParser()
+ await fs.start()
+ await parser.start()
+ watcher.file_store = fs
+ watcher.file_parser = parser
+ return watcher
+
+
+async def teardown_watcher(watcher: LiteFileWatcher) -> None:
+ """Close the manually-started subcomponents."""
+ await watcher.file_parser.close()
+ await watcher.file_store.close()
+
+
+def write_file(path: Path, content: str = "x") -> Path:
+ """Create or overwrite a file and return the path."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def test_watch_filter_default_md():
+ """Default suffix_filters=['md'] passes .md files and rejects others."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ (Path(tmpdir) / "vault").mkdir()
+ watcher = await make_watcher()
+
+ assert watcher.watch_filter(Change.added, "/x/foo.md")
+ assert not watcher.watch_filter(Change.added, "/x/foo.txt")
+ assert not watcher.watch_filter(Change.added, "/x/foo")
+
+ print("✓ test_watch_filter_default_md passed")
+
+ asyncio.run(run())
+
+
+def test_watch_filter_custom_suffix():
+ """Custom suffix_filters override the default."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ (Path(tmpdir) / "vault").mkdir()
+ watcher = await make_watcher(suffix_filters=["txt", ".rst"])
+
+ assert watcher.watch_filter(Change.added, "/x/foo.txt")
+ assert watcher.watch_filter(Change.added, "/x/foo.rst")
+ assert not watcher.watch_filter(Change.added, "/x/foo.md")
+
+ print("✓ test_watch_filter_custom_suffix passed")
+
+ asyncio.run(run())
+
+
+def test_watch_filter_no_filter_passes_all():
+ """When suffix_filters is empty (set after init), watch_filter passes everything."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ (Path(tmpdir) / "vault").mkdir()
+ watcher = await make_watcher()
+ # Constructor coerces [] / None → ["md"]; clear it directly to exercise the no-filter branch.
+ watcher.suffix_filters = []
+
+ assert watcher.watch_filter(Change.added, "/x/foo")
+ assert watcher.watch_filter(Change.added, "/x/foo.md")
+
+ print("✓ test_watch_filter_no_filter_passes_all passed")
+
+ asyncio.run(run())
+
+
+def test_relative_and_absolute_path_helpers():
+ """_get_relative_path strips working_path; _get_absolute_path resolves against it."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ (Path(tmpdir) / "vault").mkdir()
+ watcher = await make_watcher()
+
+ # Use watcher.working_path to match the same realpath form (macOS /var ↔ /private/var).
+ abs_in = (watcher.working_path / "vault" / "a.md").absolute()
+ assert watcher._get_relative_path(abs_in) == "vault/a.md"
+
+ # Path outside working_path → returns absolute
+ outside = Path("/opt/elsewhere/x.md").absolute()
+ assert watcher._get_relative_path(outside) == str(outside)
+
+ # _get_absolute_path: relative resolves under working_path
+ assert watcher._get_absolute_path("vault/a.md") == watcher.working_path / "vault/a.md"
+ # absolute stays absolute
+ assert watcher._get_absolute_path(str(abs_in)) == abs_in
+
+ print("✓ test_relative_and_absolute_path_helpers passed")
+
+ asyncio.run(run())
+
+
+def test_scan_existing_files_finds_md_recursive():
+ """scan_existing_files returns md files under watch_paths, recursively."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "alpha")
+ write_file(vault / "sub" / "b.md", "beta")
+ write_file(vault / "ignore.txt", "skip") # filtered by suffix
+ watcher = await make_watcher()
+
+ files = await watcher.scan_existing_files()
+ rels = set(files.keys())
+ assert "vault/a.md" in rels
+ assert "vault/sub/b.md" in rels
+ assert "vault/ignore.txt" not in rels
+
+ print("✓ test_scan_existing_files_finds_md_recursive passed")
+
+ asyncio.run(run())
+
+
+def test_scan_existing_files_non_recursive():
+ """recursive=False only scans direct children."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "alpha")
+ write_file(vault / "sub" / "b.md", "beta")
+ watcher = await make_watcher(recursive=False)
+
+ files = await watcher.scan_existing_files()
+ rels = set(files.keys())
+ assert "vault/a.md" in rels
+ assert "vault/sub/b.md" not in rels
+
+ print("✓ test_scan_existing_files_non_recursive passed")
+
+ asyncio.run(run())
+
+
+def test_on_added_indexes_files():
+ """on_added parses files and writes them into the file_store."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "hello")
+ watcher = await make_watcher()
+ try:
+ await watcher.on_added(["vault/a.md"])
+ nodes = await watcher.file_store.file_graph.get_nodes()
+ assert {n.path for n in nodes} == {"vault/a.md"}
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_on_added_indexes_files passed")
+
+ asyncio.run(run())
+
+
+def test_on_modified_replaces_node():
+ """on_modified re-parses and updates the node entry for an existing path."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ file_path = write_file(vault / "a.md", "v1")
+ watcher = await make_watcher()
+ try:
+ await watcher.on_added(["vault/a.md"])
+ node_before = (await watcher.file_store.file_graph.get_nodes(["vault/a.md"]))[0]
+ # Bump mtime + content
+ file_path.write_text("v2 different content", encoding="utf-8")
+ os.utime(file_path, (node_before.st_mtime + 10, node_before.st_mtime + 10))
+
+ await watcher.on_modified(["vault/a.md"])
+ node_after = (await watcher.file_store.file_graph.get_nodes(["vault/a.md"]))[0]
+ assert node_after.st_mtime > node_before.st_mtime
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_on_modified_replaces_node passed")
+
+ asyncio.run(run())
+
+
+def test_on_deleted_removes_node():
+ """on_deleted removes the node from the store regardless of file presence."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "hello")
+ watcher = await make_watcher()
+ try:
+ await watcher.on_added(["vault/a.md"])
+ assert {n.path for n in await watcher.file_store.file_graph.get_nodes()} == {"vault/a.md"}
+
+ await watcher.on_deleted(["vault/a.md"])
+ assert await watcher.file_store.file_graph.get_nodes() == []
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_on_deleted_removes_node passed")
+
+ asyncio.run(run())
+
+
+def test_update_store_initial_add():
+ """First update_store run on a fresh store reports all files as added."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "alpha")
+ write_file(vault / "b.md", "beta")
+ watcher = await make_watcher()
+ try:
+ counts = await watcher.update_store(dump=False)
+ assert counts == {"added": 2, "modified": 0, "deleted": 0}
+ paths = {n.path for n in await watcher.file_store.file_graph.get_nodes()}
+ assert paths == {"vault/a.md", "vault/b.md"}
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_update_store_initial_add passed")
+
+ asyncio.run(run())
+
+
+def test_update_store_detects_modify_and_delete():
+ """update_store distinguishes modified vs deleted vs added on a second pass."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ a = write_file(vault / "a.md", "alpha")
+ b = write_file(vault / "b.md", "beta")
+ watcher = await make_watcher()
+ try:
+ # Initial sync to seed the store.
+ await watcher.update_store(dump=False)
+
+ # Modify a (bump mtime), delete b, add c.
+ a.write_text("alpha-v2", encoding="utf-8")
+ os.utime(a, (9_999_999_999, 9_999_999_999))
+ b.unlink()
+ write_file(vault / "c.md", "gamma")
+
+ counts = await watcher.update_store(dump=False)
+ assert counts == {"added": 1, "modified": 1, "deleted": 1}
+ paths = {n.path for n in await watcher.file_store.file_graph.get_nodes()}
+ assert paths == {"vault/a.md", "vault/c.md"}
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_update_store_detects_modify_and_delete passed")
+
+ asyncio.run(run())
+
+
+def test_update_store_no_changes():
+ """A second sync over an unchanged tree reports zero counts."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ vault = Path(tmpdir) / "vault"
+ write_file(vault / "a.md", "alpha")
+ watcher = await make_watcher()
+ try:
+ await watcher.update_store(dump=False)
+ counts = await watcher.update_store(dump=False)
+ assert counts == {"added": 0, "modified": 0, "deleted": 0}
+ finally:
+ await teardown_watcher(watcher)
+ print("✓ test_update_store_no_changes passed")
+
+ asyncio.run(run())
+
+
+def test_missing_watch_path_filtered():
+ """watch_paths entries that don't exist are dropped from self.watch_paths."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir):
+ (Path(tmpdir) / "vault").mkdir()
+ watcher = await make_watcher(watch_paths=["vault", "ghost"])
+ assert [p.name for p in watcher.watch_paths] == ["vault"]
+ print("✓ test_missing_watch_path_filtered passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== LiteFileWatcher Tests ===")
+ test_watch_filter_default_md()
+ test_watch_filter_custom_suffix()
+ test_watch_filter_no_filter_passes_all()
+ test_relative_and_absolute_path_helpers()
+ test_scan_existing_files_finds_md_recursive()
+ test_scan_existing_files_non_recursive()
+ test_on_added_indexes_files()
+ test_on_modified_replaces_node()
+ test_on_deleted_removes_node()
+ test_update_store_initial_add()
+ test_update_store_detects_modify_and_delete()
+ test_update_store_no_changes()
+ test_missing_watch_path_filtered()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_tokenizer.py b/tests4/unittest/test_tokenizer.py
new file mode 100644
index 00000000..5dc99308
--- /dev/null
+++ b/tests4/unittest/test_tokenizer.py
@@ -0,0 +1,169 @@
+"""Tests for Tokenizers."""
+
+import asyncio
+
+from reme4.components.tokenizer import JiebaTokenizer, RegexTokenizer
+
+
+async def compare_tokenizers(texts: list[str], filter_stopwords: bool = False, name: str = ""):
+ """Compare both tokenizers on same input."""
+ jieba = JiebaTokenizer(filter_stopwords=filter_stopwords)
+ regex = RegexTokenizer(filter_stopwords=filter_stopwords)
+
+ await jieba.start()
+ await regex.start()
+
+ jieba_result = jieba.tokenize(texts)
+ regex_result = regex.tokenize(texts)
+
+ print(f"\n--- {name} ---")
+ print(f"输入: {texts}")
+ print(f"Jieba: {jieba_result}")
+ print(f"Regex: {regex_result}")
+
+ await jieba.close()
+ await regex.close()
+
+ return jieba_result, regex_result
+
+
+def test_basic_chinese():
+ """Test basic Chinese text."""
+
+ async def run():
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我爱北京天安门", "今天天气很好"],
+ name="纯中文",
+ )
+
+ assert "北京" in jieba_result[0] or "天安门" in jieba_result[0]
+ assert "我" in regex_result[0]
+ print("✓ test_basic_chinese passed")
+
+ asyncio.run(run())
+
+
+def test_basic_english():
+ """Test basic English text."""
+
+ async def run():
+ _, regex_result = await compare_tokenizers(
+ ["I love Beijing very much"],
+ name="英文",
+ )
+
+ assert "love" in regex_result[0]
+ assert "beijing" in regex_result[0]
+ print("✓ test_basic_english passed")
+
+ asyncio.run(run())
+
+
+def test_mixed_chinese_english():
+ """Test mixed Chinese-English text."""
+
+ async def run():
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我用 Python 学习 machine learning 和 iPhone15 Pro。"],
+ name="中英混合",
+ )
+
+ assert "python" in jieba_result[0]
+ assert "python" in regex_result[0]
+ print("✓ test_mixed_chinese_english passed")
+
+ asyncio.run(run())
+
+
+def test_open_example():
+ """Test the 'open' example."""
+
+ async def run():
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我觉得open很好呀,能分好次吗?"],
+ name="'open' 案例",
+ )
+
+ # open 保持完整
+ assert "open" in jieba_result[0]
+ assert "open" in regex_result[0]
+
+ # Regex 中文按字拆分
+ assert "我" in regex_result[0]
+ assert "很" in regex_result[0]
+
+ print("✓ test_open_example passed")
+
+ asyncio.run(run())
+
+
+def test_with_stopwords():
+ """Test with stopwords filtering."""
+
+ async def run():
+ jieba_result, regex_result = await compare_tokenizers(
+ ["我觉得open很好呀,能分好次吗?"],
+ filter_stopwords=True,
+ name="停用词过滤",
+ )
+
+ # 停用词被过滤
+ assert "吗" not in jieba_result[0]
+ assert "吗" not in regex_result[0]
+ assert "的" not in jieba_result[0]
+
+ print("✓ test_with_stopwords passed")
+
+ asyncio.run(run())
+
+
+def test_multiple_texts():
+ """Test multiple texts at once."""
+
+ async def run():
+ texts = [
+ "我爱北京天安门",
+ "I love Python programming",
+ "今天学习 machine learning",
+ ]
+ jieba_result, regex_result = await compare_tokenizers(texts, name="多个文本")
+
+ assert len(jieba_result) == 3
+ assert len(regex_result) == 3
+ print("✓ test_multiple_texts passed")
+
+ asyncio.run(run())
+
+
+def test_tokenizer_lifecycle():
+ """Test tokenizer start/close lifecycle."""
+
+ async def run():
+ tokenizer = JiebaTokenizer(filter_stopwords=True)
+
+ assert not tokenizer.is_started
+ assert len(tokenizer.stopwords) == 0
+
+ await tokenizer.start()
+ assert tokenizer.is_started
+ assert len(tokenizer.stopwords) > 0
+
+ await tokenizer.close()
+ assert not tokenizer.is_started
+ assert len(tokenizer.stopwords) == 0
+
+ print("✓ test_tokenizer_lifecycle passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== Tokenizer Tests ===")
+ test_basic_chinese()
+ test_basic_english()
+ test_mixed_chinese_english()
+ test_open_example()
+ test_with_stopwords()
+ test_multiple_texts()
+ test_tokenizer_lifecycle()
+ print("\n所有测试通过!")
From fdc36a22bc7a886669345cd658c6d07a46c2a4ab Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Sun, 17 May 2026 18:37:12 +0800
Subject: [PATCH 10/16] docs(cli): add comprehensive CLI commands documentation
(#237)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* docs(cli): add comprehensive CLI commands documentation
- Document CLI entry point and argument parsing mechanism
- Add detailed command reference with parameters and behaviors
- Include usage examples for common operations like start, search, and reindex
- Describe backend options and service configuration overrides
- Explain local vs server-side command execution patterns
- Provide table format documentation for all available actions
* docs(reme_design): update CLI command documentation with detailed action descriptions
- Rename section from "CLI 指令" to "基础Job" and add author attribution
- Add comprehensive table documenting all available actions with parameters and behaviors
- Include detailed explanations for input/output parameters, defaults, and internal workflows
- Update example usage commands with proper parameter passing syntax
- Add metadata information for each action including health checks and component details
- Clarify the difference between local actions and server-forwarded actions
- Document the new list action that intercepts at client side without forwarding to server
---
docs4/reme_design.md | 66 +++++++++++++++++++++++++++++++++++++-------
1 file changed, 56 insertions(+), 10 deletions(-)
diff --git a/docs4/reme_design.md b/docs4/reme_design.md
index 66acb982..15e7ee28 100644
--- a/docs4/reme_design.md
+++ b/docs4/reme_design.md
@@ -12,17 +12,63 @@ reme4 version
# 基础Job
@jinli
-说明:📥 输入参数 | 📤 输出 | ⭐ 必填 | 🎚️ 默认值 | 🛠️ 内部行为
-| 分类 | 能力 (register name) | 参数 & 行为 |
-|-----------|--------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| 🌐 通用 | 🆘 `help` (`help_step`) | 📥 无 | 📤 `answer` 一行一个 job:`🛠️ \`{name}\` — {description} 📥 {params}`,参数渲染为 `name:type*`(必填) / `name:type={default}` / `name:type` | 📊 `metadata.job_count` | 🛠️ 自动跳过名为 `help` 的 job |
-| 🌐 通用 | 🩺 `health_check` (`health_check_step`) | 📥 无 | 📤 `answer = "✅/❌ ReMe v{version} - healthy/unhealthy"` | 📊 `metadata.health = {version, healthy, components}` | 🧩 覆盖组件:`embedding_model`(🟢 is_started/is_healthy/model_name/dimensions/cache_size/memory) · `file_graph`(🕸️ n_nodes/n_edges/n_virtual\|n_pending/memory) · `file_store`(📦 n_chunks/n_chunks_with_embedding/memory) · `file_watcher`(👀 background_running/watch_paths) · `keyword_index`(🔤 n_docs/vocab_size/memory) | 🛠️ deep sizeof(含 numpy.nbytes),未启动 / 后台未跑 / embedding 不健康 → ❌ |
-| 🌐 通用 | 🏷️ `version` (`version_step`) | 📥 无 | 📤 `answer = reme4.__version__` | 📊 `metadata.version` |
-| 🌐 通用 | 🔄 `reindex` (`reindex_step`) | 📥 无 | 📤 `answer = "🔄 Reindexed {added} file(s)"` | 📊 `metadata.counts = {added, ...}` | 🛠️ 流程:`file_watcher.close()` → `file_store.clear()` → `file_watcher.update_store()` → `file_watcher.start()`(finally 保证重启) |
-| 🔎 search | 🔍 `search` (`search_step`) | 📥 `query:str` ⭐ | 🎚️ `limit:int=5`(>0) | 🎚️ `min_score:float=0.0` | ⚖️ `vector_weight:float=0.7` ∈[0,1](keyword 权 = 1-vw)| 🔀 `candidate_multiplier:float=3.0`(candidates = min(200, limit×mult))| 🔗 `expand_links:bool=True` | 🔢 `max_links_per_direction:int=10` | 🎚️ `search_filter:dict={}` | 📤 `answer` 每命中一行 `path:start-end [score=… vector=… keyword=…] text` + 缩进的 `→ outlinks (n)` / `← inlinks (n)` + `via predicate=… anchor=#…` | 📊 `metadata.results` / `metadata.link_expansion` / `metadata.counts={vector,keyword,returned,hybrid}` | 🛠️ 并行 `vector_search` + `keyword_search` → RRF 融合(K=60,按 chunk.id 合并)→ `min_score` 过滤 → `limit` 截断 → 邻居 meta 注入 |
-| 🧪 demo | 🪄 `demo_echo` (`demo_echo_step1` + `step2`) | 📥 `query:str=""` | 🎚️ `min_score:float=0.5` | 🛠️ step1:`processed_query = query.strip().lower()`,`adjusted_min_score = min_score * 0.9`,写回 context | 📤 step2:`answer = "echo: {processed_query} (min_score={adjusted_min_score})"` | 📊 `metadata = {step, query, min_score, processed_query, adjusted_min_score}` |
-| 🌊 demo | 🌊 `stream_demo` (`stream_demo_step1` + `step2`) | 📥 `query:str=""` | 🎚️ `repeat:int=10` | 🎚️ `interval:float=0.1`(秒/字符)| 🛠️ step1:`stream_text = query * repeat` 写回 context | 📤 step2:按字符 `add_stream_string(ch, ChunkEnum.CONTENT)` 流式输出,`asyncio.sleep(interval)` 节流 |
+入口:`reme4/reme.py::main()` → `parse_args(*sys.argv[1:])` 解析首个位置参数为 `action`,后续 `key=value` 解析为 kwargs(支持
+`service.port=8080` 的 dot notation;自动剥离 `--` / `-` 前缀;值会做 bool / int / float / JSON 转换)。
+
+调用模式:
+
+- `start`:本地启动 `ReMe(Application)` 服务(不经过 client)
+- `find_reme`:本地探测正在运行的 reme,不调用服务
+- `list`:在 client 端拦截,不转发到服务端,直接返回 action 目录
+- 其他 action:通过 `call_server(action, **kwargs)` → `R.get(ComponentEnum.CLIENT, backend)` 实例化客户端并流式打印(任意未列出的
+ step register name 都按本规则透传)
+
+通用可选参数 `backend:str=http`(取值 `http` / `mcp`,对应 `reme4/components/client/{http_client,mcp_client}.py` 中
+`@R.register` 注册名);服务端默认 host/port 见 `reme4/constants.py`,可由 `start` 端通过 `service.host=` / `service.port=`
+覆盖。
+
+说明:📥 输入参数 | 📤 输出 | ⭐ 必填 | 🎚️ 默认值 | 🛠️ 内部行为 | 📊 metadata
+
+| 分类 | 指令 (register name) | 入口 | 参数 & 行为 |
+|------------|--------------------------------------------------|-------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 🚀 本地 | 🟢 `start` | `reme.py:30` → `ReMe(**kwargs).run_app()` | 📥 可选 `config=`(默认加载 `reme4/config/default.yaml`,`.yaml/.yml/.json` 都支持,含 `${ENV:-default}` 占位符)| 可选 `service.host=` / `service.port=` 等任意 dot-notation 覆盖 | 🛠️ 流程:`load_env()` → `resolve_app_config(**kwargs)` deep merge → `precheck_start(svc)`(`utils/service_utils.py:72`:目标 host:port 已有 reme → 打印 `reme already running ...` 直接返回;端口被其他进程占用 → stderr 提示 `port {port} occupied. Start on another port: reme4 start service.port=` 并 `sys.exit(1)`)→ 启动服务 |
+| 🚀 本地 | 🧭 `find_reme` | `reme.py:36` → `utils/service_utils.py:89` | 📥 无 | 📤 发现服务则 stdout 打印 `HOST={host} PORT={port} PID={pid or 'unknown'}`;未发现则 stderr 提示 `reme not started. Try: reme start` 并 `sys.exit(1)` | 🛠️ 流程:先探 `REME_DEFAULT_HOST:REME_DEFAULT_PORT`(`health_check` 命中算 `reme`),再 `pgrep -af "reme.* start"` 扫描其他端口 |
+| 🛰️ 客户端 | 📜 `list` | `components/client/base_client.py:36` | 📥 无 | 📤 服务端可用 action 目录(JSON,`indent=2 ensure_ascii=False`)| 🛠️ 在 `BaseClient.__call__` 中拦截,不进入 `_execute`,直接调用 `list_actions()`(HTTP/MCP backend 各自实现) |
+| 🌐 通用 step | 🆘 `help` (`help_step`) | `call_server("help")` | 📥 无 | 📤 `answer` 一行一个 job:`🛠️ \`{name}\` — {description} 📥 {params}`,参数渲染为 `name:type*`(必填) / `name:type={default}` / `name:type` | 📊 `metadata.job_count` | 🛠️ 自动跳过名为 `help` 的 job |
+| 🌐 通用 step | 🩺 `health_check` (`health_check_step`) | `call_server("health_check")` | 📥 无 | 📤 `answer = "✅/❌ ReMe v{version} - healthy/unhealthy"` | 📊 `metadata.health = {version, healthy, components}` | 🧩 覆盖组件:`embedding_model`(🟢 is_started/is_healthy/model_name/dimensions/cache_size/memory) · `file_graph`(🕸️ n_nodes/n_edges/n_virtual\|n_pending/memory) · `file_store`(📦 n_chunks/n_chunks_with_embedding/memory) · `file_watcher`(👀 background_running/watch_paths) · `keyword_index`(🔤 n_docs/vocab_size/memory) | 🛠️ deep sizeof(含 numpy.nbytes),未启动 / 后台未跑 / embedding 不健康 → ❌ |
+| 🌐 通用 step | 🏷️ `version` (`version_step`) | `call_server("version")` | 📥 无 | 📤 `answer = reme4.__version__` | 📊 `metadata.version` |
+| 🌐 通用 step | 🔄 `reindex` (`reindex_step`) | `call_server("reindex")` | 📥 无 | 📤 `answer = "🔄 Reindexed {added} file(s)"` | 📊 `metadata.counts = {added, ...}` | 🛠️ 流程:`file_watcher.close()` → `file_store.clear()` → `file_watcher.update_store()` → `file_watcher.start()`(finally 保证重启) |
+| 🔎 search | 🔍 `search` (`search_step`) | `call_server("search", query=…, …)` | 📥 `query:str` ⭐ | 🎚️ `limit:int=5`(>0) | 🎚️ `min_score:float=0.0` | ⚖️ `vector_weight:float=0.7` ∈[0,1](keyword 权 = 1-vw)| 🔀 `candidate_multiplier:float=3.0`(candidates = min(200, limit×mult))| 🔗 `expand_links:bool=True` | 🔢 `max_links_per_direction:int=10` | 🎚️ `search_filter:dict={}` | 📤 `answer` 每命中一行 `path:start-end [score=… vector=… keyword=…] text` + 缩进的 `→ outlinks (n)` / `← inlinks (n)` + `via predicate=… anchor=#…` | 📊 `metadata.results` / `metadata.link_expansion` / `metadata.counts={vector,keyword,returned,hybrid}` | 🛠️ 并行 `vector_search` + `keyword_search` → RRF 融合(K=60,按 chunk.id 合并)→ `min_score` 过滤 → `limit` 截断 → 邻居 meta 注入 |
+| 🧪 demo | 🪄 `demo_echo` (`demo_echo_step1` + `step2`) | `call_server("demo_echo", query=…, min_score=…)` | 📥 `query:str=""` | 🎚️ `min_score:float=0.5` | 🛠️ step1:`processed_query = query.strip().lower()`,`adjusted_min_score = min_score * 0.9`,写回 context | 📤 step2:`answer = "echo: {processed_query} (min_score={adjusted_min_score})"` | 📊 `metadata = {step, query, min_score, processed_query, adjusted_min_score}` |
+| 🌊 demo | 🌊 `stream_demo` (`stream_demo_step1` + `step2`) | `call_server("stream_demo", query=…, repeat=…, interval=…)` | 📥 `query:str=""` | 🎚️ `repeat:int=10` | 🎚️ `interval:float=0.1`(秒/字符)| 🛠️ step1:`stream_text = query * repeat` 写回 context | 📤 step2:按字符 `add_stream_string(ch, ChunkEnum.CONTENT)` 流式输出,`asyncio.sleep(interval)` 节流 |
+
+使用示例:
+
+```bash
+# 启动(默认 default.yaml)
+reme4 start
+
+# 指定 config 与服务端口
+reme4 start config=paw.yaml service.port=8181
+
+# 查找在跑的 reme
+reme4 find_reme
+# HOST=127.0.0.1 PORT=8000 PID=12345
+
+# 列出所有可用 action(client 端处理,不转服务端)
+reme4 list
+
+# 转发到服务端的 step:所有 key=value 透传为 step kwargs
+reme4 help
+reme4 health_check
+reme4 version
+reme4 reindex
+reme4 search query="latency 问题" limit=10 min_score=0.2 vector_weight=0.6
+
+# 通过 MCP backend 调用
+reme4 search query="..." backend=mcp
+```
@sen
| tags | stat | 返回特定tag信息 |
From 357415dd49eb24c52b316593c9bd0c3b30490b53 Mon Sep 17 00:00:00 2001
From: Sen Huang <48879559+ployts@users.noreply.github.com>
Date: Mon, 18 May 2026 14:25:14 +0800
Subject: [PATCH 11/16] feat: add Neo4j file graph support and markdown parser
with wikilink extraction (#240)
* feat: add Neo4j file graph support and markdown parser with wikilink extraction
- Add Neo4jFileGraph implementation for property-graph storage with
virtual/real node handling and link management
- Introduce LinkedFileParser for markdown files with frontmatter,
wikilink graph extraction, and full-skeleton chunking
- Update pyproject.toml to include pyyaml, mistletoe, and neo4j
dependencies
- Modify .gitignore to exclude /vault and structure.md
- Change reme CLI entry point from reme_ai.main to remecli.reme
- Register new neo4j and md components in respective registries
* refactor(file-graph): add chunk_ids support to Neo4jFileGraph
Add chunk_ids field to File node properties in Neo4jFileGraph to
enable better content chunk tracking and management.
BREAKING CHANGE: File node schema now includes chunk_ids property
which may affect existing integrations.
feat(parser): implement wikilink resolution logic
Move path resolution logic from utils/path_resolver to
linked_file_parser module and enhance wikilink resolution with
folder-note rule support and improved error handling.
fix(tests): update test assertions and variable names
Update test cases to reflect changes in data structures and
variable naming conventions across various components.
chore(config): update package entry point reference
Change reme CLI entry point from remecli.reme:main to
reme_ai.reme:main in pyproject.toml.
refactor(utils): remove deprecated path_resolver module
Remove the old path_resolver utility module as its functionality
has been moved to linked_file_parser.
docs(file-graph): update Neo4jFileGraph documentation
Update class docstrings and comments to reflect new chunk_ids
property and other structural changes.
style(formatting): adjust code formatting and line breaks
Minor formatting improvements including line length optimization
and consistent spacing adjustments throughout the codebase.
* fix(pyproject.toml): correct entry point for reme command
Change the entry point from "reme_ai.reme:main" to "reme_ai.main:main"
to fix the module reference for the reme command in project scripts.
---
.gitignore | 2 +-
pyproject.toml | 3 +
reme4/components/file_graph/__init__.py | 3 +-
.../components/file_graph/neo4j_file_graph.py | 450 +++++++++++
reme4/components/file_parser/__init__.py | 3 +-
.../file_parser/linked_file_parser.py | 729 ++++++++++++++++++
reme4/utils/logo_utils.py | 28 +-
tests4/unittest/test_linked_file_parser.py | 284 +++++++
tests4/unittest/test_neo4j_file_graph.py | 338 ++++++++
9 files changed, 1831 insertions(+), 9 deletions(-)
create mode 100644 reme4/components/file_graph/neo4j_file_graph.py
create mode 100644 reme4/components/file_parser/linked_file_parser.py
create mode 100644 tests4/unittest/test_linked_file_parser.py
create mode 100644 tests4/unittest/test_neo4j_file_graph.py
diff --git a/.gitignore b/.gitignore
index b20570b5..264c7372 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,4 +42,4 @@ meta_memory/*
**/data/*.json
*.db
memories/*
-.reme/*
\ No newline at end of file
+.reme/*
diff --git a/pyproject.toml b/pyproject.toml
index c9f644af..81039167 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,6 +63,9 @@ dependencies = [
"transformers>=4.57.3",
"uvicorn>=0.40.0",
"watchfiles>=1.1.1",
+ "pyyaml>=6.0.3",
+ "mistletoe",
+ "neo4j",
]
[project.optional-dependencies]
diff --git a/reme4/components/file_graph/__init__.py b/reme4/components/file_graph/__init__.py
index 12b01355..257bc7dc 100644
--- a/reme4/components/file_graph/__init__.py
+++ b/reme4/components/file_graph/__init__.py
@@ -2,6 +2,7 @@
from .base_file_graph import BaseFileGraph
from .local_file_graph import LocalFileGraph
+from .neo4j_file_graph import Neo4jFileGraph
from .nx_file_graph import NxFileGraph
-__all__ = ["BaseFileGraph", "LocalFileGraph", "NxFileGraph"]
+__all__ = ["BaseFileGraph", "LocalFileGraph", "Neo4jFileGraph", "NxFileGraph"]
diff --git a/reme4/components/file_graph/neo4j_file_graph.py b/reme4/components/file_graph/neo4j_file_graph.py
new file mode 100644
index 00000000..869c2b18
--- /dev/null
+++ b/reme4/components/file_graph/neo4j_file_graph.py
@@ -0,0 +1,450 @@
+"""Neo4j-backed file graph.
+
+Property-graph mapping:
+
+ Real node: (:File {path, st_mtime, title, description, tags,
+ chunk_ids, links_json, extra_json})
+ Virtual node: (:File {path}) — placeholder created when something
+ links to a path that hasn't been upserted yet.
+
+ Edge: (:File)-[:LINKS {idx, anchor, predicate}]->(:File)
+
+The ``links_json`` property doubles as the "is real" marker — its
+presence means the node was upserted with a payload; its absence
+means the node exists only because some edge points at it. This
+mirrors ``NxFileGraph`` exactly: ``upsert_nodes`` promotes virtuals
+in place, ``delete_nodes`` demotes back to virtual (or fully removes
+if nothing points here), and ``get_outlinks`` excludes edges into
+virtuals so the agent never sees dangling pointers.
+
+``path`` is the unique key (constraint enforced on ``_start``).
+Frontmatter goes into flat properties; arbitrary extras land in
+``extra_json``. The full ``FileLink[]`` payload is also stored as
+``links_json`` so ``rebuild_links`` can rebuild the relationship
+graph from per-node payloads after backend repair / migration.
+
+Adjacency policy: trusts ``FileLink.path`` directly — no internal
+wikilink resolution. The parser pipeline (with the external
+resolver) produces safe links where ``link.path`` is already a
+vault-relative target.
+
+Conditional dependency: the ``neo4j`` driver loads lazily; the
+import error fires at ``_start`` (boot), not at first call.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from .base_file_graph import BaseFileGraph
+from ..component_registry import R
+from ...schema import FileLink, FileNode
+from ...schema.file_node import FileFrontMatter
+
+
+_TYPED_FRONTMATTER_FIELDS = {"title", "description", "tags"}
+_LINK_FIELDS = {"source_path", "target_path", "target_anchor", "predicate"}
+
+# Properties that distinguish a "real" node from a virtual placeholder.
+# Listed for the demote query (delete_nodes) so we can REMOVE them all.
+_REAL_PROPS = (
+ "st_mtime",
+ "title",
+ "description",
+ "tags",
+ "chunk_ids",
+ "links_json",
+ "extra_json",
+)
+
+
+@R.register("neo4j")
+class Neo4jFileGraph(BaseFileGraph):
+ """Neo4j-backed file graph; trusts ``FileLink.path`` for adjacency.
+
+ Connection params (constructor kwargs):
+ uri: bolt URL, e.g. ``bolt://localhost:7687``
+ user: auth user (default ``neo4j``)
+ password: auth password
+ database: target db name (default ``neo4j``)
+ """
+
+ def __init__(
+ self,
+ uri: str = "bolt://localhost:7687",
+ user: str = "neo4j",
+ password: str = "neo4j",
+ database: str = "neo4j",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._uri: str = uri
+ self._user: str = user
+ self._password: str = password
+ self._database: str = database
+ self._driver = None
+
+ # -- Lifecycle ---------------------------------------------------------
+
+ async def _start(self) -> None:
+ await super()._start()
+ try:
+ from neo4j import AsyncGraphDatabase
+ except ImportError as e:
+ raise ImportError(
+ "Neo4jFileGraph requires the neo4j driver. Install with `pip install neo4j`.",
+ ) from e
+ self._driver = AsyncGraphDatabase.driver(
+ self._uri,
+ auth=(self._user, self._password),
+ )
+ async with self._session() as session:
+ await session.run(
+ "CREATE CONSTRAINT file_path_unique IF NOT EXISTS FOR (f:File) REQUIRE f.path IS UNIQUE",
+ )
+ real, virtual, edges = await self._counts(session)
+ self.logger.info(
+ f"Neo4jFileGraph '{self.graph_name}' connected at "
+ f"{self._uri}/{self._database}: "
+ f"{real} nodes, {edges} edges, {virtual} virtual",
+ )
+
+ async def _close(self) -> None:
+ if self._driver is not None:
+ await self._driver.close()
+ self._driver = None
+ await super()._close()
+
+ def _session(self):
+ assert self._driver is not None, "Neo4jFileGraph not started"
+ return self._driver.session(database=self._database)
+
+ @staticmethod
+ async def _counts(session) -> tuple[int, int, int]:
+ rec = await session.run(
+ """
+ MATCH (f:File)
+ WITH count(CASE WHEN f.links_json IS NOT NULL THEN 1 END) AS real,
+ count(CASE WHEN f.links_json IS NULL THEN 1 END) AS virtual
+ OPTIONAL MATCH ()-[r:LINKS]->()
+ RETURN real, virtual, count(r) AS edges
+ """,
+ )
+ row = await rec.single()
+ if row is None:
+ return 0, 0, 0
+ return int(row["real"] or 0), int(row["virtual"] or 0), int(row["edges"] or 0)
+
+ # -- Node CRUD ---------------------------------------------------------
+
+ async def upsert_nodes(self, nodes: list[FileNode]) -> None:
+ """Upsert in one tx: SET props (promotes virtual to real), drop
+ existing outgoing edges, re-emit edges (auto-creating virtual
+ nodes for unindexed targets)."""
+ if not nodes:
+ return
+ payload = [
+ {
+ "path": node.path,
+ "props": self._node_props(node),
+ "links": [
+ {
+ "idx": i,
+ "anchor": link.target_anchor,
+ "predicate": link.predicate,
+ "target": link.target_path,
+ }
+ for i, link in enumerate(node.links)
+ if link.target_path
+ ],
+ }
+ for node in nodes
+ ]
+ async with self._session() as session:
+ await session.execute_write(self._upsert_nodes_tx, payload)
+
+ @staticmethod
+ async def _upsert_nodes_tx(tx, payload):
+ # 1. Upsert node props (promotes virtual → real where necessary).
+ await tx.run(
+ """
+ UNWIND $items AS n
+ MERGE (f:File {path: n.path})
+ SET f += n.props
+ """,
+ items=payload,
+ )
+ # 2. Drop existing outgoing edges from these sources.
+ await tx.run(
+ """
+ UNWIND $paths AS p
+ MATCH (f:File {path: p})-[r:LINKS]->()
+ DELETE r
+ """,
+ paths=[item["path"] for item in payload],
+ )
+ # 3. Re-emit edges; MERGE on target auto-creates virtual nodes
+ # for unindexed targets.
+ await tx.run(
+ """
+ UNWIND $items AS n
+ MATCH (s:File {path: n.path})
+ UNWIND n.links AS link
+ MERGE (t:File {path: link.target})
+ MERGE (s)-[r:LINKS {idx: link.idx}]->(t)
+ SET r.anchor = link.anchor, r.predicate = link.predicate
+ """,
+ items=payload,
+ )
+
+ async def delete_nodes(self, paths: list[str]) -> None:
+ """Demote real → virtual to preserve inbound visibility; fully
+ remove the (now-virtual) node only if no edge points at it."""
+ if not paths:
+ return
+ async with self._session() as session:
+ await session.execute_write(self._delete_nodes_tx, list(paths))
+
+ @staticmethod
+ async def _delete_nodes_tx(tx, paths):
+ # 1. Drop outgoing edges, then strip "real" properties (demote).
+ # Building the REMOVE clause from _REAL_PROPS keeps the list of
+ # properties in one place (top of module).
+ remove_clause = ", ".join(f"f.{name}" for name in _REAL_PROPS)
+ await tx.run(
+ f"""
+ UNWIND $paths AS p
+ MATCH (f:File {{path: p}})
+ OPTIONAL MATCH (f)-[r:LINKS]->()
+ DELETE r
+ WITH DISTINCT f
+ REMOVE {remove_clause}
+ """,
+ paths=paths,
+ )
+ # 2. Garbage-collect: drop the virtual node entirely if nothing
+ # points at it anymore.
+ await tx.run(
+ """
+ UNWIND $paths AS p
+ MATCH (f:File {path: p})
+ WHERE f.links_json IS NULL AND NOT (f)<-[:LINKS]-()
+ DELETE f
+ """,
+ paths=paths,
+ )
+
+ async def get_nodes(self, paths: list[str] | None = None) -> list[FileNode]:
+ """Return real nodes (virtual placeholders filtered).
+
+ ``paths=None`` streams every real node ordered by path. An
+ explicit ``[]`` returns ``[]`` without hitting the database.
+ """
+ if paths is not None and not paths:
+ return []
+ async with self._session() as session:
+ if paths is None:
+ rec = await session.run(
+ """
+ MATCH (f:File)
+ WHERE f.links_json IS NOT NULL
+ RETURN f
+ ORDER BY f.path ASC
+ """,
+ )
+ else:
+ rec = await session.run(
+ """
+ UNWIND $paths AS p
+ MATCH (f:File {path: p})
+ WHERE f.links_json IS NOT NULL
+ RETURN f
+ """,
+ paths=list(paths),
+ )
+ rows = [row["f"] async for row in rec]
+ return [self._row_to_node(row) for row in rows]
+
+ async def rebuild_links(self) -> None:
+ """Defensive full rebuild from each real node's ``links_json``.
+
+ Three steps in one tx: drop all LINKS edges; drop all virtual
+ nodes; re-emit edges from per-node link payloads (re-creating
+ virtual targets as needed). Useful after manual repair or
+ schema migration.
+ """
+ async with self._session() as session:
+ rec = await session.run(
+ """
+ MATCH (f:File)
+ WHERE f.links_json IS NOT NULL
+ RETURN f.path AS p, f.links_json AS l
+ """,
+ )
+ rows = [dict(r) async for r in rec]
+
+ payload: list[dict] = []
+ for row in rows:
+ try:
+ links = json.loads(row.get("l") or "[]")
+ except json.JSONDecodeError:
+ continue
+ items = [
+ {
+ "idx": i,
+ "anchor": link.get("target_anchor"),
+ "predicate": link.get("predicate"),
+ "target": link.get("target_path"),
+ }
+ for i, link in enumerate(links)
+ if isinstance(link, dict) and link.get("target_path")
+ ]
+ payload.append({"path": row["p"], "links": items})
+
+ async with self._session() as session:
+ await session.execute_write(self._rebuild_links_tx, payload)
+
+ @staticmethod
+ async def _rebuild_links_tx(tx, payload):
+ # 1. Wipe all edges and all virtual nodes.
+ await tx.run("MATCH ()-[r:LINKS]->() DELETE r")
+ await tx.run("MATCH (f:File) WHERE f.links_json IS NULL DELETE f")
+ if not payload:
+ return
+ # 2. Re-emit edges; virtual targets reappear via MERGE.
+ await tx.run(
+ """
+ UNWIND $items AS n
+ MATCH (s:File {path: n.path})
+ UNWIND n.links AS link
+ MERGE (t:File {path: link.target})
+ MERGE (s)-[r:LINKS {idx: link.idx}]->(t)
+ SET r.anchor = link.anchor, r.predicate = link.predicate
+ """,
+ items=payload,
+ )
+
+ async def clear(self):
+ """Remove every node and edge in the configured database."""
+ async with self._session() as session:
+ await session.run("MATCH (f:File) DETACH DELETE f")
+
+ # -- Link access -------------------------------------------------------
+
+ async def get_outlinks(self, path: str) -> list[FileLink]:
+ """Outgoing links from ``path``. Source must be real; targets
+ into virtual nodes are excluded so dangling refs are invisible."""
+ async with self._session() as session:
+ rec = await session.run(
+ """
+ MATCH (s:File {path: $path})
+ WHERE s.links_json IS NOT NULL
+ MATCH (s)-[r:LINKS]->(t:File)
+ WHERE t.links_json IS NOT NULL
+ RETURN t.path AS target, r.anchor AS anchor,
+ r.predicate AS predicate, r.idx AS idx
+ ORDER BY r.idx ASC
+ """,
+ path=path,
+ )
+ rows = [dict(row) async for row in rec]
+ return [
+ FileLink(
+ source_path=path,
+ target_path=row["target"],
+ target_anchor=row.get("anchor"),
+ predicate=row.get("predicate"),
+ )
+ for row in rows
+ ]
+
+ async def get_inlinks(self, path: str) -> list[FileLink]:
+ """Incoming links to ``path`` (must be real). Sources are always
+ real because virtual nodes never have outgoing edges."""
+ async with self._session() as session:
+ rec = await session.run(
+ """
+ MATCH (t:File {path: $path})
+ WHERE t.links_json IS NOT NULL
+ MATCH (s:File)-[r:LINKS]->(t)
+ RETURN r.anchor AS anchor, r.predicate AS predicate,
+ r.idx AS idx, s.path AS source
+ ORDER BY s.path ASC, r.idx ASC
+ """,
+ path=path,
+ )
+ rows = [dict(row) async for row in rec]
+ return [
+ FileLink(
+ source_path=row["source"],
+ target_path=path,
+ target_anchor=row.get("anchor"),
+ predicate=row.get("predicate"),
+ )
+ for row in rows
+ ]
+
+ # -- Internal: row ↔ schema marshaling ---------------------------------
+
+ @staticmethod
+ def _node_props(node: FileNode) -> dict[str, Any]:
+ fm = node.front_matter
+ extras = dict(fm.__pydantic_extra__ or {})
+ return {
+ "path": node.path,
+ "st_mtime": float(node.st_mtime),
+ "title": fm.title or "",
+ "description": fm.description or "",
+ "tags": list(fm.tags or []),
+ "chunk_ids": list(node.chunk_ids or []),
+ "links_json": json.dumps(
+ [link.model_dump(exclude_none=True) for link in node.links],
+ ensure_ascii=False,
+ ),
+ "extra_json": json.dumps(extras, ensure_ascii=False, sort_keys=True),
+ }
+
+ @staticmethod
+ def _row_to_node(row) -> FileNode:
+ d = dict(row)
+ try:
+ extras = json.loads(d.get("extra_json") or "{}")
+ except json.JSONDecodeError:
+ extras = {}
+ try:
+ links_raw = json.loads(d.get("links_json") or "[]")
+ except json.JSONDecodeError:
+ links_raw = []
+ links: list[FileLink] = []
+ for link in links_raw:
+ if not isinstance(link, dict):
+ continue
+ # Defensive: strip any keys the schema doesn't recognise
+ # (e.g. legacy fields from prior schema versions).
+ clean = {k: v for k, v in link.items() if k in _LINK_FIELDS}
+ # Ensure source_path is populated — older payloads (or
+ # links written before the schema split) only carry the
+ # target side; default to the owning node's path.
+ clean.setdefault("source_path", d["path"])
+ if not clean.get("target_path"):
+ continue
+ try:
+ links.append(FileLink(**clean))
+ except Exception:
+ continue
+ fm_kwargs: dict[str, Any] = {
+ "title": d.get("title", "") or "",
+ "description": d.get("description", "") or "",
+ "tags": d.get("tags") or None,
+ }
+ fm_kwargs.update(
+ {k: v for k, v in extras.items() if k not in _TYPED_FRONTMATTER_FIELDS},
+ )
+ return FileNode(
+ path=d["path"],
+ st_mtime=float(d.get("st_mtime", 0.0)),
+ links=links,
+ chunk_ids=[str(c) for c in (d.get("chunk_ids") or [])],
+ front_matter=FileFrontMatter(**fm_kwargs),
+ )
diff --git a/reme4/components/file_parser/__init__.py b/reme4/components/file_parser/__init__.py
index 3e20d0bf..c5b62bd9 100644
--- a/reme4/components/file_parser/__init__.py
+++ b/reme4/components/file_parser/__init__.py
@@ -3,5 +3,6 @@
from .bare_file_parser import BareFileParser
from .base_file_parser import BaseFileParser
from .default_file_parser import DefaultFileParser
+from .linked_file_parser import LinkedFileParser
-__all__ = ["BareFileParser", "BaseFileParser", "DefaultFileParser"]
+__all__ = ["BareFileParser", "BaseFileParser", "DefaultFileParser", "LinkedFileParser"]
diff --git a/reme4/components/file_parser/linked_file_parser.py b/reme4/components/file_parser/linked_file_parser.py
new file mode 100644
index 00000000..2c825dfa
--- /dev/null
+++ b/reme4/components/file_parser/linked_file_parser.py
@@ -0,0 +1,729 @@
+"""Markdown file parser — frontmatter + wikilink graph + AST tree chunks.
+
+Each chunk carries the **complete heading skeleton** of the document
+with its content inlined under the section that owns it; other sections
+appear as bare headings so the reader always sees a full document map.
+
+Pipeline: build mistletoe AST → ``MdNode`` tree (sections nest by
+heading level) → recursive chunk (try whole subtree; on overflow walk
+children — body siblings pack as a run, subsections recurse). Leaf
+blocks (table / code / list / paragraph) split on internal boundaries
+and each piece is annotated ``[Part X/N]``. Wikilinks in the body are
+extracted as graph edges, with optional Dataview-style typed predicates
+(line-level ``predicate:: [[X]]`` or inline-bracketed ``[predicate:: [[X]]]``).
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import frontmatter
+
+
+from .base_file_parser import BaseFileParser
+from ..component_registry import R
+from ..file_graph import BaseFileGraph
+from ...enumeration import ComponentEnum
+from ...schema import (
+ FileChunk,
+ FileLink,
+ FileFrontMatter,
+ FileNode,
+)
+
+
+# -- Wikilink resolution --------------------------------------------------
+#
+# Wikilinks are a markdown user-facing convention: ``[[Alice]]`` should
+# resolve to ``topics/Alice/Alice.md`` (or wherever the file lives).
+# This short-form / implicit-``.md`` / folder-note resolution lives here
+# at the markdown boundary rather than as a generic utility — file-IO
+# steps require full vault-relative paths and never use these helpers.
+
+
+def _complete_md(target: str) -> str:
+ """Apply implicit ``.md`` rule for wikilink targets."""
+ if not target:
+ return target
+ last = target.rsplit("/", 1)[-1]
+ return target if "." in last else target + ".md"
+
+
+def _filter_folder_note(target: str, paths: list[str]) -> list[str]:
+ """Apply folder-note rule: when both ``X.md`` and ``X/X.md`` exist,
+ prefer ``X/X.md``. Sorted for determinism.
+ """
+ if not paths:
+ return []
+ stem = Path(target).stem
+ folder_hits = sorted(p for p in paths if Path(p).parent.name == stem)
+ return folder_hits or sorted(paths)
+
+
+async def _resolve_wikilink(graph: BaseFileGraph, target: str) -> list[str]:
+ """Resolve a wikilink target to vault-relative path(s).
+
+ Returns:
+ ``[path]`` for an unambiguous match,
+ ``[path, path, ...]`` for short-form ambiguity (caller may
+ fan out one FileLink per candidate), or
+ ``[]`` when nothing matches (dangling — caller drops the link).
+ """
+ if not target:
+ return []
+ target = _complete_md(target)
+ if "/" in target:
+ nodes = await graph.get_nodes([target])
+ return [target] if nodes else []
+ matches = [n.path for n in await graph.get_nodes() if Path(n.path).name == target]
+ return _filter_folder_note(target, matches)
+
+
+# -- Wikilink extraction --------------------------------------------------
+
+
+_WIKILINK_RE = re.compile(
+ r"""
+ (?:!)?
+ \[\[
+ (?P[^\]\|\#\n]+?)
+ (?:\#(?P[^\]\|\n]+))?
+ (?:\|[^\]\n]+)?
+ \]\]
+ """,
+ re.VERBOSE,
+)
+
+_DATAVIEW_LINE_RE = re.compile(
+ r"^[ \t]*(?:[-*+][ \t]+)?(?P[A-Za-z][A-Za-z0-9_]*)\s*::\s*(?P.+?)\s*$",
+ re.MULTILINE,
+)
+
+_INLINE_FIELD_OPEN_RE = re.compile(r"\[(?P[A-Za-z][A-Za-z0-9_]*)\s*::\s*")
+
+
+def _iter_inline_fields(text: str) -> list[tuple[int, int, str]]:
+ """Find inline-bracketed ``[predicate:: …]`` field spans by depth scan."""
+ out: list[tuple[int, int, str]] = []
+ for m in _INLINE_FIELD_OPEN_RE.finditer(text):
+ depth = 1
+ i = m.end()
+ n = len(text)
+ while i < n:
+ c = text[i]
+ if c == "\n":
+ break
+ if c == "[":
+ depth += 1
+ elif c == "]":
+ depth -= 1
+ if depth == 0:
+ out.append((m.start(), i + 1, m.group("predicate")))
+ break
+ i += 1
+ return out
+
+
+def _predicate_for(
+ text: str,
+ pos: int,
+ inline_spans: list[tuple[int, int, str]],
+) -> str | None:
+ """Resolve the predicate governing a wikilink at offset ``pos``.
+
+ Precedence: inline-bracketed > line-level Dataview > none.
+ """
+ for field_start, field_end, predicate in inline_spans:
+ if field_start <= pos < field_end:
+ return predicate
+ line_start = text.rfind("\n", 0, pos) + 1
+ line_end = text.find("\n", pos)
+ if line_end == -1:
+ line_end = len(text)
+ m = _DATAVIEW_LINE_RE.match(text[line_start:line_end])
+ if m and line_start + m.start("value") <= pos:
+ return m.group("predicate")
+ return None
+
+
+async def _extract_links(
+ graph: BaseFileGraph,
+ text: str,
+ source_path: str,
+) -> list[FileLink]:
+ """Find every wikilink in ``text``, resolve targets, emit FileLinks.
+
+ Short-path ambiguity **expands** into one FileLink per candidate so
+ the body's wikilink is recorded against every plausible target.
+ Dangling targets are dropped. Results are deduped by
+ ``(target_path, predicate, target_anchor)`` preserving order.
+ """
+ if not text:
+ return []
+ inline_spans = _iter_inline_fields(text)
+ out: list[FileLink] = []
+ seen: set[tuple] = set()
+ for wm in _WIKILINK_RE.finditer(text):
+ target = wm.group("target").strip()
+ if not target:
+ continue
+ anchor_raw = wm.group("anchor")
+ anchor = anchor_raw.strip() if anchor_raw else None
+ predicate = _predicate_for(text, wm.start(), inline_spans)
+ resolved_paths = await _resolve_wikilink(graph, target)
+ if not resolved_paths:
+ continue
+ for resolved in resolved_paths:
+ key = (resolved, predicate, anchor)
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(
+ FileLink(
+ source_path=source_path,
+ target_path=resolved,
+ target_anchor=anchor,
+ predicate=predicate,
+ ),
+ )
+ return out
+
+
+# -- AST node + helpers ---------------------------------------------------
+
+
+@dataclass
+class MdNode:
+ """``root`` / ``section`` (heading + children until equal-or-shallower
+ heading) / ``body`` (one mistletoe block; ``block`` keeps the original).
+
+ ``text`` is the rendered subtree (own heading excluded for sections).
+ ``desc_toc`` caches the section-only DFS outline of descendants
+ (own heading excluded), used as the TOC suffix when emitting chunks
+ inside a section. Line ranges span the full subtree.
+ """
+
+ kind: str # "root" | "section" | "body"
+ heading: str | None = None
+ level: int = 0
+ children: list["MdNode"] = field(default_factory=list)
+ block: Any = None
+ text: str = ""
+ start_line: int = 0
+ end_line: int = 0
+ desc_toc: str = ""
+
+
+def _heading_text(node: Any, renderer) -> str:
+ """Heading text without `#` markers (for outline)."""
+ rendered = renderer.render(node).rstrip("\n")
+ if rendered.startswith("#"):
+ return rendered.lstrip("#").strip()
+ return rendered.split("\n", 1)[0].strip()
+
+
+def _finalize(n: MdNode) -> None:
+ """Bottom-up pass: propagate line ranges, populate ``n.text`` (rendered
+ subtree, own heading excluded for sections) and ``n.desc_toc`` (DFS
+ section outline of descendants)."""
+ parts: list[str] = []
+ desc_lines: list[str] = []
+ for c in n.children:
+ _finalize(c)
+ if c.kind == "section":
+ heading = f"{'#' * c.level} {c.heading or ''}"
+ parts.append(f"{heading}\n\n{c.text}" if c.text else heading)
+ desc_lines.append(f"{heading}\n\n{c.desc_toc}" if c.desc_toc else heading)
+ elif c.text:
+ parts.append(c.text)
+ if n.children:
+ first = n.children[0].start_line
+ n.start_line = min(n.start_line, first) if n.start_line else first
+ n.end_line = max(c.end_line for c in n.children)
+ elif n.end_line < n.start_line:
+ n.end_line = n.start_line
+ if n.kind != "body":
+ n.text = "\n\n".join(parts)
+ n.desc_toc = "\n\n".join(desc_lines)
+
+
+def _toc_join(*parts: str) -> str:
+ """Concatenate TOC fragments with ``\\n\\n``, skipping empty ones."""
+ return "\n\n".join(p for p in parts if p)
+
+
+def _subtree_toc(n: MdNode) -> str:
+ """Section's heading + descendants TOC — its contribution to a parent's
+ ``desc_toc``. For root (no own heading) this is just ``desc_toc``."""
+ if n.kind != "section" or n.heading is None:
+ return n.desc_toc
+ heading = f"{'#' * n.level} {n.heading}"
+ return f"{heading}\n\n{n.desc_toc}" if n.desc_toc else heading
+
+
+# -- Parser ---------------------------------------------------------------
+
+
+@R.register("md")
+class LinkedFileParser(BaseFileParser):
+ """Markdown parser: frontmatter + wikilink edges + full-skeleton chunks."""
+
+ def __init__(
+ self,
+ encoding: str = "utf-8",
+ chunk_chars: int = 2000,
+ embed_toc: bool = True,
+ file_graph: str = "default",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.encoding = encoding
+ self.chunk_chars = max(100, chunk_chars)
+ self.embed_toc = embed_toc
+ self._file_graph_name: str = file_graph
+
+ def _resolve_file_graph(self) -> BaseFileGraph | None:
+ """Lazily fetch the configured file_graph from app_context.
+
+ Lazy (rather than ``_start``) so the parser doesn't impose a
+ component start-order constraint, and so tests can construct
+ the parser without a graph wired up.
+ """
+ if self.app_context is None:
+ return None
+ graphs = self.app_context.components.get(ComponentEnum.FILE_GRAPH, {})
+ graph = graphs.get(self._file_graph_name)
+ if graph is None:
+ return None
+ if not isinstance(graph, BaseFileGraph):
+ raise TypeError(
+ f"Expected BaseFileGraph, got {type(graph).__name__}",
+ )
+ return graph
+
+ async def parse(self, path: str | Path) -> tuple[FileNode, list[FileChunk]]:
+ from mistletoe.markdown_renderer import MarkdownRenderer
+ from mistletoe.block_token import Document
+
+ file_path = Path(path)
+ rel_path = self._get_relative_path(path)
+ post = frontmatter.loads(file_path.read_text(encoding=self.encoding))
+
+ chunks: list[FileChunk] = []
+ if post.content and post.content.strip():
+ with MarkdownRenderer() as renderer:
+ tree = self._build_tree(Document(post.content), renderer)
+ chunks = self._chunk_node(tree, "", "", rel_path, renderer)
+
+ links: list[FileLink] = []
+ graph = self._resolve_file_graph()
+ if graph is not None:
+ links = await _extract_links(graph, post.content, rel_path)
+
+ node = FileNode(
+ path=rel_path,
+ st_mtime=file_path.stat().st_mtime,
+ chunk_ids=[chunk.id for chunk in chunks],
+ links=links,
+ front_matter=FileFrontMatter(**dict(post.metadata)),
+ )
+ return node, chunks
+
+ def _build_tree(self, doc: Any, renderer) -> MdNode:
+ """Heading-level stack folds mistletoe's flat children into nested
+ sections; non-headings attach as ``body`` to the current section
+ (or root before the first heading)."""
+ from mistletoe.markdown_renderer import BlankLine
+ from mistletoe.block_token import (
+ Heading,
+ SetextHeading,
+ )
+
+ root = MdNode(kind="root", start_line=1, end_line=1)
+ stack: list[MdNode] = [root]
+ for child in doc.children or []:
+ if isinstance(child, BlankLine):
+ continue
+ line = getattr(child, "line_number", None) or stack[-1].start_line
+ if isinstance(child, (Heading, SetextHeading)):
+ level = max(1, getattr(child, "level", 1))
+ while len(stack) > 1 and stack[-1].level >= level:
+ stack.pop()
+ sec = MdNode(
+ kind="section",
+ heading=_heading_text(child, renderer),
+ level=level,
+ start_line=line,
+ )
+ stack[-1].children.append(sec)
+ stack.append(sec)
+ continue
+ rendered = renderer.render(child).rstrip("\n")
+ if not rendered:
+ continue
+ stack[-1].children.append(
+ MdNode(
+ kind="body",
+ block=child,
+ text=rendered,
+ start_line=line,
+ end_line=line + rendered.count("\n"),
+ ),
+ )
+ _finalize(root)
+ return root
+
+ # -- Recursive chunker ------------------------------------------------
+
+ def _chunk_node(
+ self,
+ node: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ renderer,
+ ) -> list[FileChunk]:
+ """Try the whole subtree; on overflow split (leaf) or descend.
+ ``before``/``after`` are TOC fragments that bracket each emitted
+ chunk's content (chunk text = ``before + content + after``).
+ As we descend, the prefix grows with section headings already
+ passed and the suffix shrinks correspondingly.
+ """
+ if not node.text:
+ return []
+ if node.kind == "section":
+ heading_line = f"{'#' * node.level} {node.heading or ''}"
+ before_self = _toc_join(before, heading_line)
+ else:
+ before_self = before
+ if len(node.text) <= self.chunk_chars:
+ return [
+ self._make_chunk(
+ before_self,
+ node.text,
+ after,
+ node.start_line,
+ node.end_line,
+ path,
+ ),
+ ]
+ if node.kind == "body":
+ return self._split_leaf(node, before, after, path, renderer)
+ after_inside = _toc_join(node.desc_toc, after)
+ sub_tocs = [_subtree_toc(c) for c in node.children if c.kind == "section"]
+ chunks: list[FileChunk] = []
+ accumulated = before_self
+ sec_idx = 0
+ run: list[MdNode] = []
+ for c in node.children:
+ if c.kind == "section":
+ if run:
+ chunks.extend(
+ self._chunk_body_run(
+ run,
+ before_self,
+ after_inside,
+ path,
+ renderer,
+ ),
+ )
+ run = []
+ remaining = "\n\n".join(sub_tocs[sec_idx + 1 :])
+ chunks.extend(
+ self._chunk_node(
+ c,
+ accumulated,
+ _toc_join(remaining, after),
+ path,
+ renderer,
+ ),
+ )
+ accumulated = _toc_join(accumulated, sub_tocs[sec_idx])
+ sec_idx += 1
+ else:
+ run.append(c)
+ if run:
+ chunks.extend(
+ self._chunk_body_run(
+ run,
+ before_self,
+ after_inside,
+ path,
+ renderer,
+ ),
+ )
+ return chunks
+
+ def _chunk_body_run(
+ self,
+ run: list[MdNode],
+ before: str,
+ after: str,
+ path: str,
+ renderer,
+ ) -> list[FileChunk]:
+ """Greedy-pack consecutive body siblings under the same TOC slot.
+ No ``[Part X/N]`` markers — distinct blocks, not a leaf split.
+ Oversized single body recurses to ``_split_leaf``."""
+ composite_size = sum(len(b.text) for b in run) + 2 * max(0, len(run) - 1)
+ if composite_size <= self.chunk_chars:
+ return [
+ self._make_chunk(
+ before,
+ "\n\n".join(b.text for b in run),
+ after,
+ run[0].start_line,
+ run[-1].end_line,
+ path,
+ ),
+ ]
+
+ chunks: list[FileChunk] = []
+ bucket: list[MdNode] = []
+ bucket_chars = 0
+
+ def flush() -> None:
+ nonlocal bucket, bucket_chars
+ if not bucket:
+ return
+ chunks.append(
+ self._make_chunk(
+ before,
+ "\n\n".join(b.text for b in bucket),
+ after,
+ bucket[0].start_line,
+ bucket[-1].end_line,
+ path,
+ ),
+ )
+ bucket = []
+ bucket_chars = 0
+
+ for body in run:
+ if len(body.text) > self.chunk_chars:
+ flush()
+ chunks.extend(self._split_leaf(body, before, after, path, renderer))
+ continue
+ sep = 2 if bucket else 0
+ if bucket_chars + sep + len(body.text) > self.chunk_chars:
+ flush()
+ sep = 0
+ bucket.append(body)
+ bucket_chars += sep + len(body.text)
+ flush()
+ return chunks
+
+ # -- Leaf splitters: build (text, start, end) units, hand off to packer
+
+ def _split_leaf(
+ self,
+ body: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ renderer,
+ ) -> list[FileChunk]:
+ from mistletoe.block_token import (
+ CodeFence,
+ List,
+ Table,
+ )
+
+ block = body.block
+ if isinstance(block, Table):
+ return self._split_table(body, before, after, path)
+ if isinstance(block, CodeFence):
+ return self._split_code(body, before, after, path)
+ if isinstance(block, List):
+ return self._split_list(body, before, after, path, renderer)
+ return self._split_lines(body, before, after, path)
+
+ def _split_table(
+ self,
+ body: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ ) -> list[FileChunk]:
+ """Repeat header + separator on every chunk."""
+ from mistletoe.block_token import TableRow
+
+ lines = body.text.split("\n")
+ header, data = "\n".join(lines[:2]), lines[2:]
+ rows = [r for r in (body.block.children or []) if isinstance(r, TableRow)]
+ base = body.start_line + 2
+
+ def line_of(i: int) -> int:
+ return rows[i].line_number if i < len(rows) and rows[i].line_number else base + i
+
+ units = [(text, line_of(i), line_of(i)) for i, text in enumerate(data)]
+ return self._emit_packed(
+ units,
+ before,
+ after,
+ path,
+ joiner="\n",
+ wrap=f"{header}\n{{inner}}",
+ )
+
+ def _split_code(
+ self,
+ body: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ ) -> list[FileChunk]:
+ """Repeat fence opener + closer on every chunk."""
+ code = body.block
+ indent = " " * (code.indentation or 0)
+ fence = f"{indent}{code.delimiter}"
+ opener = f"{fence}{code.info_string or ''}"
+ raw = (code.children[0].content if code.children else "").rstrip("\n")
+ if not raw:
+ return []
+ start = body.start_line + 1
+ units = [(indent + ln, start + i, start + i) for i, ln in enumerate(raw.split("\n"))]
+ return self._emit_packed(
+ units,
+ before,
+ after,
+ path,
+ joiner="\n",
+ wrap=f"{opener}\n{{inner}}\n{fence}",
+ allow_empty=True,
+ )
+
+ def _split_list(
+ self,
+ body: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ renderer,
+ ) -> list[FileChunk]:
+ """Pack list items; oversized items emit alone (overflow accepted)."""
+ from mistletoe.block_token import ListItem
+
+ items = [c for c in (body.block.children or []) if isinstance(c, ListItem)]
+ if not items:
+ return self._split_lines(body, before, after, path)
+ units: list[tuple[str, int, int]] = []
+ for it in items:
+ text = renderer.render(it).rstrip("\n")
+ if not text:
+ continue
+ line = it.line_number or body.start_line
+ units.append((text, line, line + text.count("\n")))
+ return self._emit_packed(
+ units,
+ before,
+ after,
+ path,
+ joiner="\n",
+ wrap="{inner}",
+ )
+
+ def _split_lines(
+ self,
+ body: MdNode,
+ before: str,
+ after: str,
+ path: str,
+ ) -> list[FileChunk]:
+ """Last-resort line-greedy split for paragraphs / quotes / html."""
+ start = body.start_line
+ units = [(line, start + i, start + i) for i, line in enumerate(body.text.split("\n"))]
+ return self._emit_packed(
+ units,
+ before,
+ after,
+ path,
+ joiner="\n",
+ wrap="{inner}",
+ )
+
+ def _emit_packed(
+ self,
+ units: list[tuple[str, int, int]],
+ before: str,
+ after: str,
+ path: str,
+ joiner: str,
+ wrap: str,
+ allow_empty: bool = False,
+ ) -> list[FileChunk]:
+ """Greedy-pack units into ``wrap`` envelopes; emit each piece.
+
+ Envelope (table header, code fence) counts against ``chunk_chars``;
+ TOC (when on) is additive prefix/suffix downstream. Oversized
+ units overflow rather than truncate. Multi-piece outputs get
+ ``[Part X/N]`` markers; single pieces don't.
+ """
+ envelope = len(wrap.replace("{inner}", ""))
+ budget = max(64, self.chunk_chars - envelope)
+ sep_len = len(joiner)
+
+ parts: list[tuple[str, int, int]] = []
+ bucket: list[tuple[str, int, int]] = []
+ bucket_chars = 0
+
+ def flush() -> None:
+ nonlocal bucket, bucket_chars
+ if not bucket:
+ return
+ inner = joiner.join(t for t, _, _ in bucket)
+ parts.append((inner, bucket[0][1], bucket[-1][2]))
+ bucket = []
+ bucket_chars = 0
+
+ for text, s, e in units:
+ if not text and not allow_empty:
+ continue
+ sep = sep_len if bucket else 0
+ if bucket_chars + sep + len(text) > budget:
+ flush()
+ sep = 0
+ bucket.append((text, s, e))
+ bucket_chars += sep + len(text)
+ flush()
+
+ total = len(parts)
+ return [
+ self._make_chunk(
+ before,
+ (
+ f"[Part {idx}/{total}]\n\n{wrap.replace('{inner}', inner)}"
+ if total > 1
+ else wrap.replace("{inner}", inner)
+ ),
+ after,
+ s,
+ e,
+ path,
+ )
+ for idx, (inner, s, e) in enumerate(parts, 1)
+ ]
+
+ # -- Emit -------------------------------------------------------------
+
+ def _make_chunk(
+ self,
+ before: str,
+ content: str,
+ after: str,
+ start_line: int,
+ end_line: int,
+ path: str,
+ ) -> FileChunk:
+ """Build one ``FileChunk`` — text is ``before + content + after``
+ when ``embed_toc``, otherwise just ``content``."""
+ text = _toc_join(before, content, after) if self.embed_toc else content
+ return FileChunk(
+ path=path,
+ start_line=start_line,
+ end_line=end_line,
+ text=text,
+ ).set_hash_id()
diff --git a/reme4/utils/logo_utils.py b/reme4/utils/logo_utils.py
index 2a70ab94..b4795330 100644
--- a/reme4/utils/logo_utils.py
+++ b/reme4/utils/logo_utils.py
@@ -1,6 +1,8 @@
"""Startup banner with ASCII logo and service metadata."""
+import colorsys
import importlib.metadata
+import random
from typing import TYPE_CHECKING
from rich.console import Console, Group
@@ -20,8 +22,20 @@ def get_version(package_name: str) -> str:
return ""
+def _hsv_rgb(h: float, s: float = 0.85, v: float = 0.98) -> tuple[int, int, int]:
+ """HSV → 0-255 RGB tuple. High saturation+value keeps colors vibrant."""
+ r, g, b = colorsys.hsv_to_rgb(h % 1.0, s, v)
+ return int(r * 255), int(g * 255), int(b * 255)
+
+
def print_logo(app_config: "ApplicationConfig"):
- """Print gradient ASCII logo and runtime config (backend, URL, versions)."""
+ """Print rainbow ASCII logo and runtime config (backend, URL, versions).
+
+ Color: each startup picks a random hue rotation; both horizontal
+ (across each line) and vertical (line-to-line) sweep ~half the
+ hue wheel, so the banner shows a fresh multi-color rainbow gradient
+ every run.
+ """
ascii_art = [
r" ██████╗ ███████╗ ███╗ ███╗ ███████╗ ",
r" ██╔══██╗ ██╔════╝ ████╗ ████║ ██╔════╝ ",
@@ -31,16 +45,18 @@ def print_logo(app_config: "ApplicationConfig"):
r" ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ",
]
- start_color = (85, 239, 196)
- end_color = (162, 155, 254)
+ hue_base = random.random() # random starting hue per startup
+ horizontal_span = 0.5 # half the wheel left-to-right
+ vertical_shift = 0.08 # small per-line nudge for 2D rainbow
logo_text = Text()
- for line in ascii_art:
+ for line_idx, line in enumerate(ascii_art):
line_len = max(1, len(line) - 1)
+ line_hue_start = hue_base + line_idx * vertical_shift
for i, char in enumerate(line):
ratio = i / line_len
- rgb = tuple(int(s + (e - s) * ratio) for s, e in zip(start_color, end_color))
- logo_text.append(char, style=f"bold rgb({rgb[0]},{rgb[1]},{rgb[2]})")
+ r, g, b = _hsv_rgb(line_hue_start + horizontal_span * ratio)
+ logo_text.append(char, style=f"bold rgb({r},{g},{b})")
logo_text.append("\n")
info_table = Table.grid(padding=(0, 1))
diff --git a/tests4/unittest/test_linked_file_parser.py b/tests4/unittest/test_linked_file_parser.py
new file mode 100644
index 00000000..034d19ff
--- /dev/null
+++ b/tests4/unittest/test_linked_file_parser.py
@@ -0,0 +1,284 @@
+"""Tests for LinkedFileParser (markdown parser + wikilink extraction)."""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+
+from reme4.components.file_graph import LocalFileGraph
+from reme4.components.file_parser import LinkedFileParser
+from reme4.schema import FileNode
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+def _write_md(tmpdir: str, name: str, body: str) -> str:
+ """Drop a markdown file under tmpdir, return its path."""
+ path = os.path.join(tmpdir, name)
+ if "/" in name:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(body)
+ return path
+
+
+async def _make_graph(*nodes: FileNode) -> LocalFileGraph:
+ """Build a started LocalFileGraph seeded with the given nodes."""
+ graph = LocalFileGraph()
+ await graph.start()
+ if nodes:
+ await graph.upsert_nodes(list(nodes))
+ return graph
+
+
+def test_parse_empty_file():
+ """An empty .md → FileNode, no chunks, no links."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "empty.md", "")
+ parser = LinkedFileParser()
+ node, chunks = await parser.parse(path)
+ assert chunks == []
+ assert node.links == []
+ assert node.chunk_ids == []
+ print("✓ test_parse_empty_file passed")
+
+ asyncio.run(run())
+
+
+def test_parse_frontmatter_only():
+ """Front-matter without body → FileNode with metadata, no chunks/links."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "fm.md", "---\ntitle: Demo\ntags: [a, b]\n---\n")
+ parser = LinkedFileParser()
+ node, chunks = await parser.parse(path)
+ assert chunks == []
+ assert node.front_matter.title == "Demo"
+ assert list(node.front_matter.tags or []) == ["a", "b"]
+ print("✓ test_parse_frontmatter_only passed")
+
+ asyncio.run(run())
+
+
+def test_parse_small_body_one_chunk():
+ """A body shorter than chunk_chars produces exactly one chunk that contains the body."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "small.md", "# Hello\n\nworld")
+ parser = LinkedFileParser(chunk_chars=2000)
+ _, chunks = await parser.parse(path)
+ assert len(chunks) == 1
+ assert "world" in chunks[0].text
+ assert chunks[0].start_line >= 1
+ assert chunks[0].end_line >= chunks[0].start_line
+ print("✓ test_parse_small_body_one_chunk passed")
+
+ asyncio.run(run())
+
+
+def test_parse_oversized_body_splits():
+ """A body that exceeds chunk_chars produces multiple chunks."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ # 30 paragraphs of 100 chars each ≈ 3000 chars body
+ body = "# Big\n\n" + "\n\n".join("p" * 100 for _ in range(30))
+ path = _write_md(tmp, "big.md", body)
+ parser = LinkedFileParser(chunk_chars=500, embed_toc=False)
+ _, chunks = await parser.parse(path)
+ assert len(chunks) > 1
+ print("✓ test_parse_oversized_body_splits passed")
+
+ asyncio.run(run())
+
+
+def test_parse_chunk_ids_match_node_chunk_ids():
+ """FileNode.chunk_ids should match the ids of the chunks returned."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "ids.md", "# X\n\nbody")
+ parser = LinkedFileParser()
+ node, chunks = await parser.parse(path)
+ assert node.chunk_ids == [c.id for c in chunks]
+ print("✓ test_parse_chunk_ids_match_node_chunk_ids passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_empty_when_no_graph():
+ """Without an app_context / graph, links stay empty even if the body has wikilinks."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ path = _write_md(tmp, "x.md", "see [[Alice]] and [[Bob]]")
+ parser = LinkedFileParser()
+ node, _ = await parser.parse(path)
+ assert node.links == []
+ print("✓ test_parse_links_empty_when_no_graph passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_resolved_via_graph():
+ """With a graph that knows the targets, wikilinks become FileLinks with resolved target_path."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _make_graph(
+ FileNode(path="topics/Alice.md", st_mtime=0.0),
+ FileNode(path="topics/Bob.md", st_mtime=0.0),
+ )
+ path = _write_md(tmp, "note.md", "see [[Alice]] and [[Bob#sec]]")
+ parser = LinkedFileParser()
+ parser._resolve_file_graph = lambda: graph
+ node, _ = await parser.parse(path)
+ triples = {(link.target_path, link.target_anchor, link.predicate) for link in node.links}
+ assert ("topics/Alice.md", None, None) in triples
+ assert ("topics/Bob.md", "sec", None) in triples
+ # source_path always equals the node's own path
+ for link in node.links:
+ assert link.source_path == node.path
+ await graph.close()
+ print("✓ test_parse_links_resolved_via_graph passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_predicate_inline_and_line():
+ """Both `pred:: [[X]]` (line-level) and `[pred:: [[X]]]` (inline) propagate predicate."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _make_graph(
+ FileNode(path="A.md", st_mtime=0.0),
+ FileNode(path="B.md", st_mtime=0.0),
+ )
+ body = "extends:: [[A]]\n\nsome [concerns:: [[B]]] inline\n"
+ path = _write_md(tmp, "note.md", body)
+ parser = LinkedFileParser()
+ parser._resolve_file_graph = lambda: graph
+ node, _ = await parser.parse(path)
+ pairs = {(link.target_path, link.predicate) for link in node.links}
+ assert ("A.md", "extends") in pairs
+ assert ("B.md", "concerns") in pairs
+ await graph.close()
+ print("✓ test_parse_links_predicate_inline_and_line passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_short_path_ambiguity_expands():
+ """A short link matching multiple nodes expands to one FileLink per candidate."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _make_graph(
+ FileNode(path="topics/Bob.md", st_mtime=0.0),
+ FileNode(path="people/Bob.md", st_mtime=0.0),
+ )
+ path = _write_md(tmp, "note.md", "ref [[Bob]]")
+ parser = LinkedFileParser()
+ parser._resolve_file_graph = lambda: graph
+ node, _ = await parser.parse(path)
+ targets = sorted(link.target_path for link in node.links)
+ assert targets == ["people/Bob.md", "topics/Bob.md"]
+ await graph.close()
+ print("✓ test_parse_links_short_path_ambiguity_expands passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_dangling_dropped():
+ """Wikilink to a non-existent target is silently dropped (no graph node)."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _make_graph(FileNode(path="topics/Alice.md", st_mtime=0.0))
+ path = _write_md(tmp, "note.md", "[[Alice]] and [[Ghost]]")
+ parser = LinkedFileParser()
+ parser._resolve_file_graph = lambda: graph
+ node, _ = await parser.parse(path)
+ targets = {link.target_path for link in node.links}
+ assert targets == {"topics/Alice.md"}
+ await graph.close()
+ print("✓ test_parse_links_dangling_dropped passed")
+
+ asyncio.run(run())
+
+
+def test_parse_links_deduped():
+ """Repeated wikilinks with the same (target, predicate, anchor) emit one FileLink."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _make_graph(FileNode(path="A.md", st_mtime=0.0))
+ path = _write_md(tmp, "note.md", "[[A]] again [[A]] and [[A]]")
+ parser = LinkedFileParser()
+ parser._resolve_file_graph = lambda: graph
+ node, _ = await parser.parse(path)
+ assert len([link for link in node.links if link.target_path == "A.md"]) == 1
+ await graph.close()
+ print("✓ test_parse_links_deduped passed")
+
+ asyncio.run(run())
+
+
+def test_parse_min_chunk_chars_clamped():
+ """chunk_chars below 100 should be clamped to 100."""
+ parser = LinkedFileParser(chunk_chars=10)
+ assert parser.chunk_chars == 100
+ print("✓ test_parse_min_chunk_chars_clamped passed")
+
+
+def test_parse_embed_toc_prefixes_chunk_text():
+ """When embed_toc=True, chunks emitted inside a section are prefixed by the heading."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ body = "# Top\n\n## Sub\n\nbody-content"
+ path = _write_md(tmp, "toc.md", body)
+ parser = LinkedFileParser(chunk_chars=200, embed_toc=True)
+ _, chunks = await parser.parse(path)
+ # Single small section fits; check that the heading appears in text.
+ assert any("Top" in c.text for c in chunks)
+ print("✓ test_parse_embed_toc_prefixes_chunk_text passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== LinkedFileParser tests ===")
+ test_parse_empty_file()
+ test_parse_frontmatter_only()
+ test_parse_small_body_one_chunk()
+ test_parse_oversized_body_splits()
+ test_parse_chunk_ids_match_node_chunk_ids()
+ test_parse_links_empty_when_no_graph()
+ test_parse_links_resolved_via_graph()
+ test_parse_links_predicate_inline_and_line()
+ test_parse_links_short_path_ambiguity_expands()
+ test_parse_links_dangling_dropped()
+ test_parse_links_deduped()
+ test_parse_min_chunk_chars_clamped()
+ test_parse_embed_toc_prefixes_chunk_text()
+ print("\n所有测试通过!")
diff --git a/tests4/unittest/test_neo4j_file_graph.py b/tests4/unittest/test_neo4j_file_graph.py
new file mode 100644
index 00000000..fef9ef4a
--- /dev/null
+++ b/tests4/unittest/test_neo4j_file_graph.py
@@ -0,0 +1,338 @@
+"""Tests for Neo4jFileGraph.
+
+Skipped automatically if (a) the ``neo4j`` driver isn't installed,
+or (b) a Neo4j instance isn't reachable at the configured URI.
+
+Override the URI / auth via env vars:
+ NEO4J_URI (default ``bolt://localhost:7687``)
+ NEO4J_USER (default ``neo4j``)
+ NEO4J_PASSWORD (default ``neo4j``)
+ NEO4J_DATABASE (default ``neo4j``)
+"""
+
+# pylint: disable=protected-access
+
+import asyncio
+import os
+import tempfile
+
+import pytest
+
+from reme4.schema import FileLink, FileNode
+
+
+URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
+USER = os.environ.get("NEO4J_USER", "neo4j")
+PASSWORD = os.environ.get("NEO4J_PASSWORD", "neo4j")
+DATABASE = os.environ.get("NEO4J_DATABASE", "neo4j")
+
+
+try:
+ from reme4.components.file_graph import Neo4jFileGraph
+
+ _NEO4J_IMPORT_ERROR: Exception | None = None
+except Exception as e: # pragma: no cover
+ _NEO4J_IMPORT_ERROR = e
+
+
+async def _probe_neo4j() -> str | None:
+ """Try to connect; return None if OK, error string if unreachable."""
+ if _NEO4J_IMPORT_ERROR is not None:
+ return f"import failed: {_NEO4J_IMPORT_ERROR}"
+ try:
+ from neo4j import AsyncGraphDatabase
+ except ImportError as e:
+ return f"neo4j driver not installed: {e}"
+ try:
+ driver = AsyncGraphDatabase.driver(URI, auth=(USER, PASSWORD))
+ async with driver.session(database=DATABASE) as session:
+ await session.run("RETURN 1")
+ await driver.close()
+ return None
+ except Exception as e: # pragma: no cover
+ return f"connect failed: {e}"
+
+
+_PROBE_NOT_RUN = object()
+_PROBE_REASON: str | None | object = _PROBE_NOT_RUN # sentinel: "not probed"
+
+
+def _probe_reason() -> str | None:
+ """Probe Neo4j once per process; cache the outcome."""
+ global _PROBE_REASON
+ if _PROBE_REASON is _PROBE_NOT_RUN:
+ _PROBE_REASON = asyncio.run(_probe_neo4j())
+ return _PROBE_REASON # type: ignore[return-value]
+
+
+pytestmark = pytest.mark.skipif(
+ _probe_reason() is not None,
+ reason=f"Neo4j unavailable: {_probe_reason()}",
+)
+
+
+class temp_chdir:
+ """Context manager to temporarily chdir into a path and restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self.old = None
+
+ def __enter__(self):
+ self.old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self.old)
+
+
+def make_node(path: str, links: list[tuple[str, str | None]] | None = None) -> FileNode:
+ """Build a FileNode with outgoing (target_path, target_anchor) pairs."""
+ return FileNode(
+ path=path,
+ st_mtime=1.0,
+ links=[FileLink(source_path=path, target_path=t, target_anchor=a) for t, a in (links or [])],
+ )
+
+
+async def _fresh_graph() -> "Neo4jFileGraph": # type: ignore[name-defined]
+ """Build a started Neo4jFileGraph wiped clean."""
+ graph = Neo4jFileGraph(uri=URI, user=USER, password=PASSWORD, database=DATABASE)
+ await graph.start()
+ await graph.clear()
+ return graph
+
+
+def test_upsert_and_get_nodes():
+ """upsert_nodes stores; get_nodes returns by paths or all."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [make_node("a.md", [("b.md", None)]), make_node("b.md")],
+ )
+ got_all = await graph.get_nodes()
+ assert {n.path for n in got_all} == {"a.md", "b.md"}
+ got_one = await graph.get_nodes(["a.md"])
+ assert len(got_one) == 1 and got_one[0].path == "a.md"
+ assert await graph.get_nodes(["nope.md"]) == []
+ assert await graph.get_nodes([]) == []
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_upsert_and_get_nodes passed")
+
+ asyncio.run(run())
+
+
+def test_outlinks_skip_virtual_targets():
+ """get_outlinks excludes edges into virtual placeholder nodes."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("ghost.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ outs = await graph.get_outlinks("a.md")
+ assert {link.target_path for link in outs} == {"b.md"}
+ for link in outs:
+ assert link.source_path == "a.md"
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_outlinks_skip_virtual_targets passed")
+
+ asyncio.run(run())
+
+
+def test_inlinks_carry_source_path():
+ """get_inlinks returns FileLinks whose source_path is the linking node."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", "anchor1")]),
+ make_node("c.md", [("b.md", None)]),
+ make_node("b.md"),
+ ],
+ )
+ ins = await graph.get_inlinks("b.md")
+ sources = {link.source_path for link in ins}
+ assert sources == {"a.md", "c.md"}
+ # Each link's target should be the queried path.
+ for link in ins:
+ assert link.target_path == "b.md"
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_inlinks_carry_source_path passed")
+
+ asyncio.run(run())
+
+
+def test_delete_demotes_then_repromotes():
+ """delete_nodes makes a node virtual; re-upsert promotes pending edges back."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [make_node("a.md", [("b.md", None)]), make_node("b.md")],
+ )
+ assert {link.source_path for link in await graph.get_inlinks("b.md")} == {"a.md"}
+
+ await graph.delete_nodes(["b.md"])
+ assert await graph.get_nodes(["b.md"]) == []
+ # a's outlink is hidden because b is now virtual.
+ assert await graph.get_outlinks("a.md") == []
+
+ await graph.upsert_nodes([make_node("b.md")])
+ assert {link.source_path for link in await graph.get_inlinks("b.md")} == {"a.md"}
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_delete_demotes_then_repromotes passed")
+
+ asyncio.run(run())
+
+
+def test_rebuild_links_idempotent():
+ """rebuild_links reconstructs identical out/in views from per-node payloads."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [
+ make_node("a.md", [("b.md", None), ("c.md", "h")]),
+ make_node("b.md"),
+ make_node("c.md"),
+ ],
+ )
+ before_out = sorted((link.target_path, link.target_anchor) for link in await graph.get_outlinks("a.md"))
+ before_in_b = sorted(link.source_path for link in await graph.get_inlinks("b.md"))
+
+ await graph.rebuild_links()
+
+ after_out = sorted((link.target_path, link.target_anchor) for link in await graph.get_outlinks("a.md"))
+ after_in_b = sorted(link.source_path for link in await graph.get_inlinks("b.md"))
+ assert before_out == after_out
+ assert before_in_b == after_in_b
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_rebuild_links_idempotent passed")
+
+ asyncio.run(run())
+
+
+def test_clear_wipes_everything():
+ """clear() drops every node and edge in the database."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ await graph.upsert_nodes(
+ [make_node("a.md", [("b.md", None)]), make_node("b.md")],
+ )
+ await graph.clear()
+ assert await graph.get_nodes() == []
+ finally:
+ await graph.close()
+ print("✓ test_clear_wipes_everything passed")
+
+ asyncio.run(run())
+
+
+def test_node_roundtrip_preserves_frontmatter_and_links():
+ """Upsert → get_nodes round-trip preserves frontmatter + links + chunk_ids."""
+
+ async def run():
+ from reme4.schema.file_node import FileFrontMatter
+
+ with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
+ graph = await _fresh_graph()
+ try:
+ node = FileNode(
+ path="topics/Alice.md",
+ st_mtime=1234.5,
+ links=[
+ FileLink(
+ source_path="topics/Alice.md",
+ target_path="topics/Bob.md",
+ target_anchor="intro",
+ predicate="knows",
+ ),
+ ],
+ chunk_ids=["chunk-a1", "chunk-a2", "chunk-a3"],
+ front_matter=FileFrontMatter(
+ title="Alice",
+ description="a person",
+ tags=["x", "y"],
+ ),
+ )
+ await graph.upsert_nodes([node, make_node("topics/Bob.md")])
+ got = await graph.get_nodes(["topics/Alice.md"])
+ assert len(got) == 1
+ back = got[0]
+ assert back.path == "topics/Alice.md"
+ assert back.st_mtime == 1234.5
+ assert back.front_matter.title == "Alice"
+ assert back.front_matter.description == "a person"
+ assert sorted(back.front_matter.tags or []) == ["x", "y"]
+ assert back.chunk_ids == ["chunk-a1", "chunk-a2", "chunk-a3"]
+ assert len(back.links) == 1
+ link = back.links[0]
+ assert link.source_path == "topics/Alice.md"
+ assert link.target_path == "topics/Bob.md"
+ assert link.target_anchor == "intro"
+ assert link.predicate == "knows"
+
+ # An upsert with empty chunk_ids should also round-trip cleanly
+ # (and overwrite the previous list).
+ await graph.upsert_nodes(
+ [
+ FileNode(
+ path="topics/Alice.md",
+ st_mtime=1234.5,
+ chunk_ids=[],
+ ),
+ ],
+ )
+ got2 = await graph.get_nodes(["topics/Alice.md"])
+ assert got2 and got2[0].chunk_ids == []
+ finally:
+ await graph.clear()
+ await graph.close()
+ print("✓ test_node_roundtrip_preserves_frontmatter_and_links passed")
+
+ asyncio.run(run())
+
+
+if __name__ == "__main__":
+ if _probe_reason() is not None:
+ print(f"Skipping Neo4j tests: {_probe_reason()}")
+ else:
+ print("\n=== Neo4jFileGraph tests ===")
+ test_upsert_and_get_nodes()
+ test_outlinks_skip_virtual_targets()
+ test_inlinks_carry_source_path()
+ test_delete_demotes_then_repromotes()
+ test_rebuild_links_idempotent()
+ test_clear_wipes_everything()
+ test_node_roundtrip_preserves_frontmatter_and_links()
+ print("\n所有测试通过!")
From 68bd95b494e46518d600e954e39701d6e398f423 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Mon, 18 May 2026 15:00:02 +0800
Subject: [PATCH 12/16] =?UTF-8?q?refactor(steps):=20Add=20job=20management?=
=?UTF-8?q?=20methods=20and=20support=20registering=20t=E2=80=A6=20(#242)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(steps): Add job management methods and support registering them as tools
Added methods to the `BaseStep` class for retrieving, running, and registering jobs as tools, enhancing the functionality of the step class.
* fix doc
---
reme4/steps/base_step.py | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/reme4/steps/base_step.py b/reme4/steps/base_step.py
index a710f457..d32c4445 100644
--- a/reme4/steps/base_step.py
+++ b/reme4/steps/base_step.py
@@ -5,8 +5,10 @@ from abc import abstractmethod, ABC
from typing import TypeVar, TYPE_CHECKING
from agentscope.formatter import FormatterBase
+from agentscope.message import TextBlock
from agentscope.model import ChatModelBase
from agentscope.token import TokenCounterBase
+from agentscope.tool import Toolkit, ToolResponse
from ..components.embedding import BaseEmbeddingModel
from ..components.file_parser import BaseFileParser
@@ -15,10 +17,12 @@ from ..components.file_watcher import BaseFileWatcher
from ..components.prompt_handler import PromptHandler
from ..components.runtime_context import RuntimeContext
from ..enumeration import ComponentEnum
+from ..schema import Response
from ..utils import get_logger
if TYPE_CHECKING:
from ..components import ApplicationContext
+ from ..components.job import BaseJob
T = TypeVar("T")
@@ -138,3 +142,33 @@ class BaseStep(ABC):
def copy(self, **kwargs) -> "BaseStep":
"""Construct a new instance from the original init args, applying overrides."""
return self.__class__(*self._init_args, **{**self._init_kwargs, **kwargs})
+
+ def get_job(self, name: str) -> "BaseJob | None":
+ """Return a job by name."""
+ if self.app_context is None:
+ raise RuntimeError("Cannot get job without an app context")
+ return self.app_context.jobs.get(name)
+
+ async def run_job(self, name: str, **kwargs) -> Response:
+ """Execute a job by name and kwargs, return the final response."""
+ job: "BaseJob | None" = self.get_job(name)
+ if job is None:
+ raise RuntimeError(f"Job {name} not found")
+ return await job(**kwargs)
+
+ def add_as_tool(self, toolkit: Toolkit, job_name: str) -> None:
+ """Add the step as a tool to the toolkit."""
+ job: "BaseJob | None" = self.get_job(job_name)
+ if job is None:
+ raise RuntimeError(f"Job {job_name} not found")
+
+ async def run_job(**kwargs) -> ToolResponse:
+ response = await job(**kwargs)
+ return ToolResponse(content=[TextBlock(type="text", text=response.answer)])
+
+ toolkit.register_tool_function(
+ tool_func=run_job,
+ func_name=job_name,
+ func_description=job.description,
+ json_schema=job.parameters,
+ )
From 0e5a9f50346f6d5a19251e278a7bb20d3d2355f9 Mon Sep 17 00:00:00 2001
From: Joshua <75612919+Joshuaakaspace@users.noreply.github.com>
Date: Mon, 18 May 2026 12:48:23 +0530
Subject: [PATCH 13/16] fix(reme_light): dedupe default watch paths on
case-insensitive filesystems (#234)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(reme_light): dedupe default watch paths on case-insensitive filesystems
On Windows NTFS and macOS HFS+, ``MEMORY.md`` and ``memory.md`` resolve to
the same physical file. ``ReMeLight.__init__`` hardcoded both spellings in
the default ``watch_paths`` list, so the memory markdown file was indexed
twice on those filesystems, wasting embedding calls and producing duplicate
search hits.
Dedupe the default candidate list using ``os.path.normcase`` as the
comparison key. On case-sensitive filesystems normcase is the identity
function, so both spellings continue to be watched there. The original
path strings are preserved, the caller-supplied ``watch_paths`` path is
untouched, and only the built-in fallback is affected.
Fixes #228
* refactor(reme_light): simplify watch path dedup via existence check
Replace the os.path.normcase-based dedup loop with a direct exists()
check that picks one of MEMORY.md / memory.md. On case-insensitive
filesystems both spellings resolve to the same file so exists() returns
true for both, naturally avoiding a duplicate watch — including on
macOS where os.path.normcase is the identity function and the previous
approach silently did nothing.
Co-Authored-By: Claude Opus 4.7
---------
Co-authored-by: jinli.yl
Co-authored-by: Claude Opus 4.7
---
reme/reme_light.py | 14 +++---
tests/test_reme_light_watch_paths.py | 67 ++++++++++++++++++++++++++++
2 files changed, 75 insertions(+), 6 deletions(-)
create mode 100644 tests/test_reme_light_watch_paths.py
diff --git a/reme/reme_light.py b/reme/reme_light.py
index 91e738ed..ca52a394 100644
--- a/reme/reme_light.py
+++ b/reme/reme_light.py
@@ -135,12 +135,14 @@ class ReMeLight(Application):
self.vector_weight: float = vector_weight
self.candidate_multiplier: float = candidate_multiplier
- # Build the file watcher config: use provided watch_paths if given, otherwise use defaults
- _default_watch_paths = [
- str(self.working_path / "MEMORY.md"),
- str(self.working_path / "memory.md"),
- str(self.memory_path),
- ]
+ # Pick the existing memory markdown file. On case-insensitive filesystems
+ # (Windows NTFS, macOS APFS/HFS+) ``MEMORY.md`` and ``memory.md`` are the
+ # same file, so this also avoids watching it twice. Default to ``MEMORY.md``
+ # when neither exists yet.
+ _memory_md = self.working_path / "MEMORY.md"
+ if not _memory_md.exists() and (self.working_path / "memory.md").exists():
+ _memory_md = self.working_path / "memory.md"
+ _default_watch_paths = [str(_memory_md), str(self.memory_path)]
if default_file_watcher_config and default_file_watcher_config.get("watch_paths"):
_merged_file_watcher_config = default_file_watcher_config
else:
diff --git a/tests/test_reme_light_watch_paths.py b/tests/test_reme_light_watch_paths.py
new file mode 100644
index 00000000..4d0a91f9
--- /dev/null
+++ b/tests/test_reme_light_watch_paths.py
@@ -0,0 +1,67 @@
+"""
+Tests for the default watch path construction in ``ReMeLight``.
+
+Verifies that the built-in watch list picks a single ``MEMORY.md`` /
+``memory.md`` spelling so the file is not indexed twice on case-insensitive
+filesystems (Windows NTFS, macOS APFS/HFS+). See agentscope-ai/ReMe#228.
+"""
+
+# pylint: disable=redefined-outer-name,protected-access,missing-function-docstring,missing-class-docstring
+
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from reme.reme_light import ReMeLight
+
+
+@pytest.fixture
+def temp_working_dir():
+ with tempfile.TemporaryDirectory() as tmp:
+ yield tmp
+
+
+def _captured_watch_paths(working_dir: str, *, default_file_watcher_config=None):
+ """Capture the ``watch_paths`` that ``ReMeLight`` would forward to its
+ parent ``Application.__init__``, without spinning up the full app stack."""
+ captured: dict = {}
+
+ def _capture(*_args, **kwargs):
+ captured.update(kwargs)
+
+ with patch("reme.reme_light.Application.__init__", _capture):
+ ReMeLight(
+ working_dir=working_dir,
+ default_file_watcher_config=default_file_watcher_config,
+ )
+
+ return list((captured.get("default_file_watcher_config") or {}).get("watch_paths", []))
+
+
+class TestDefaultWatchPaths:
+ def test_defaults_to_uppercase_memory_md_when_neither_exists(self, temp_working_dir):
+ paths = _captured_watch_paths(temp_working_dir)
+ memory_dir = str(Path(temp_working_dir).absolute() / "memory")
+ assert paths == [str(Path(temp_working_dir).absolute() / "MEMORY.md"), memory_dir]
+
+ def test_picks_lowercase_memory_md_when_only_it_exists(self, temp_working_dir):
+ (Path(temp_working_dir) / "memory.md").write_text("")
+ if (Path(temp_working_dir) / "MEMORY.md").exists():
+ pytest.skip("case-insensitive filesystem treats both spellings as one file")
+ paths = _captured_watch_paths(temp_working_dir)
+ assert paths[0] == str(Path(temp_working_dir).absolute() / "memory.md")
+
+ def test_prefers_uppercase_memory_md_when_it_exists(self, temp_working_dir):
+ (Path(temp_working_dir) / "MEMORY.md").write_text("")
+ paths = _captured_watch_paths(temp_working_dir)
+ assert paths[0] == str(Path(temp_working_dir).absolute() / "MEMORY.md")
+
+ def test_user_provided_watch_paths_pass_through(self, temp_working_dir):
+ custom = [str(Path(temp_working_dir) / "notes.md")]
+ paths = _captured_watch_paths(
+ temp_working_dir,
+ default_file_watcher_config={"watch_paths": custom},
+ )
+ assert paths == custom
From b001c060869e4f5c7a36d7ed65aef320c689a4a8 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Mon, 18 May 2026 15:53:27 +0800
Subject: [PATCH 14/16] Dev/connect as tool (#243)
* refactor(steps): Add job management methods and support registering them as tools
Added methods to the `BaseStep` class for retrieving, running, and registering jobs as tools, enhancing the functionality of the step class.
* fix doc
* chore(pyproject.toml): Update dependency versions and adjust package configuration
Bump agentscope version to 1.0.19 and reorganize the core dependency configuration structure.
---
pyproject.toml | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 81039167..89d9020e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -93,11 +93,15 @@ litellm = [
"litellm==1.80.0",
]
-core = [
- "agentscope==1.0.18",
+light = [
+ "agentscope==1.0.19",
"flowllm[reme]>=0.2.0.10",
]
+core = [
+ "agentscope==1.0.19",
+]
+
[tool.setuptools.packages.find]
where = ["."]
include = ["reme_ai*", "reme*", "reme4*"]
From bee2648ad12234d9bac924ddfdcdaa49c1c8510b Mon Sep 17 00:00:00 2001
From: imrewce
Date: Tue, 19 May 2026 16:19:13 +0800
Subject: [PATCH 15/16] feat: implementation of the read step for reme
(markdown) (#245)
* feat: implementation of the read step for reme (markdown)
* fix(step): markdown read step fixing pr comments
* fix(step): more fixes for pr comments
* further fix for better review adaptation
* accept absolute path
* fixing base job exception
---
docs4/reme_design.md | 6 +
reme4/components/job/base_job.py | 1 +
reme4/config/default.yaml | 20 ++
reme4/constants.py | 5 +
reme4/steps/__init__.py | 2 +
reme4/steps/base_step.py | 16 +-
reme4/steps/crud/__init__.py | 7 +
reme4/steps/crud/_file_io.py | 109 ++++++++
reme4/steps/crud/read.py | 91 +++++++
tests4/unittest/test_crud_md_steps.py | 371 ++++++++++++++++++++++++++
10 files changed, 627 insertions(+), 1 deletion(-)
create mode 100644 reme4/steps/crud/__init__.py
create mode 100644 reme4/steps/crud/_file_io.py
create mode 100644 reme4/steps/crud/read.py
create mode 100644 tests4/unittest/test_crud_md_steps.py
diff --git a/docs4/reme_design.md b/docs4/reme_design.md
index 15e7ee28..fab618ff 100644
--- a/docs4/reme_design.md
+++ b/docs4/reme_design.md
@@ -42,6 +42,7 @@ reme4 version
| 🔎 search | 🔍 `search` (`search_step`) | `call_server("search", query=…, …)` | 📥 `query:str` ⭐ | 🎚️ `limit:int=5`(>0) | 🎚️ `min_score:float=0.0` | ⚖️ `vector_weight:float=0.7` ∈[0,1](keyword 权 = 1-vw)| 🔀 `candidate_multiplier:float=3.0`(candidates = min(200, limit×mult))| 🔗 `expand_links:bool=True` | 🔢 `max_links_per_direction:int=10` | 🎚️ `search_filter:dict={}` | 📤 `answer` 每命中一行 `path:start-end [score=… vector=… keyword=…] text` + 缩进的 `→ outlinks (n)` / `← inlinks (n)` + `via predicate=… anchor=#…` | 📊 `metadata.results` / `metadata.link_expansion` / `metadata.counts={vector,keyword,returned,hybrid}` | 🛠️ 并行 `vector_search` + `keyword_search` → RRF 融合(K=60,按 chunk.id 合并)→ `min_score` 过滤 → `limit` 截断 → 邻居 meta 注入 |
| 🧪 demo | 🪄 `demo_echo` (`demo_echo_step1` + `step2`) | `call_server("demo_echo", query=…, min_score=…)` | 📥 `query:str=""` | 🎚️ `min_score:float=0.5` | 🛠️ step1:`processed_query = query.strip().lower()`,`adjusted_min_score = min_score * 0.9`,写回 context | 📤 step2:`answer = "echo: {processed_query} (min_score={adjusted_min_score})"` | 📊 `metadata = {step, query, min_score, processed_query, adjusted_min_score}` |
| 🌊 demo | 🌊 `stream_demo` (`stream_demo_step1` + `step2`) | `call_server("stream_demo", query=…, repeat=…, interval=…)` | 📥 `query:str=""` | 🎚️ `repeat:int=10` | 🎚️ `interval:float=0.1`(秒/字符)| 🛠️ step1:`stream_text = query * repeat` 写回 context | 📤 step2:按字符 `add_stream_string(ch, ChunkEnum.CONTENT)` 流式输出,`asyncio.sleep(interval)` 节流 |
+| 📂 crud | 📖 `read` (`read_step`) | `call_server("read", path=…, …)` | 📥 `path:str` ⭐(**完整相对路径**,相对于 `working_dir`;绝对路径会被拒绝;非 `.md` 后缀拒绝)| 🎚️ `start_line:int=null`(1-based, 含端点)| 🎚️ `end_line:int=null`(1-based, 含端点)| 🎚️ `max_bytes:int=51200`(截断阈值)| 📤 `answer = 选中的行内容`,超过 `max_bytes` 时附加 `--- TRUNCATED ---` 续读指引(`start_line=…`)| 📊 `metadata.path` / `metadata.total_lines`(出错路径才会附带)| 🛠️ 流程:`BaseStep.resolve_path(raw, require_md=True)` → `aiofiles.os.stat` → `read_file_safe`(utf-8-sig BOM 容忍、UnicodeDecodeError fallback `errors=ignore`)→ `split("\n")` 切片 `[s-1:e]` → `truncate_text_output` 按字节截断保行 |
使用示例:
@@ -66,6 +67,11 @@ reme4 version
reme4 reindex
reme4 search query="latency 问题" limit=10 min_score=0.2 vector_weight=0.6
+# 读取 working_dir 下的 markdown(完整相对路径;无后缀自动补 .md;可按行切片或限制字节)
+reme4 read path=Templates/Recipe.md
+reme4 read path=Notes start_line=1 end_line=20
+reme4 read path=Big.md max_bytes=4096
+
# 通过 MCP backend 调用
reme4 search query="..." backend=mcp
```
diff --git a/reme4/components/job/base_job.py b/reme4/components/job/base_job.py
index e4681bf1..2583989f 100644
--- a/reme4/components/job/base_job.py
+++ b/reme4/components/job/base_job.py
@@ -49,5 +49,6 @@ class BaseJob(BaseComponent):
await step(context)
except Exception as e:
self.logger.exception(f"Failed to execute job: {e}")
+ context.response.success = False
context.response.answer = str(e)
return context.response
diff --git a/reme4/config/default.yaml b/reme4/config/default.yaml
index 1a501912..6e543ec9 100644
--- a/reme4/config/default.yaml
+++ b/reme4/config/default.yaml
@@ -96,6 +96,26 @@ jobs:
steps:
- backend: search_step
+ - backend: base
+ name: read
+ description: "read a markdown file (relative path under working_dir)"
+ parameters:
+ type: object
+ properties:
+ path:
+ type: string
+ description: "relative path under the working_dir (no absolute paths); markdown only"
+ start_line:
+ type: integer
+ description: "Optional, first line to read (1-based, inclusive)"
+ end_line:
+ type: integer
+ description: "Optional, last line to read (1-based, inclusive)"
+ required:
+ - path
+ steps:
+ - backend: read_step
+
- backend: stream
name: stream_demo
description: "stream demo job: repeat query 10x and stream char-by-char"
diff --git a/reme4/constants.py b/reme4/constants.py
index fce66e64..819fbadd 100644
--- a/reme4/constants.py
+++ b/reme4/constants.py
@@ -5,3 +5,8 @@ REME_SERVICE_INFO = "REME_SERVICE_INFO"
REME_DEFAULT_HOST = "127.0.0.1"
REME_DEFAULT_PORT = 2333
+
+# CRUD steps: file IO limits and truncation marker (shared across CRUD steps).
+DEFAULT_MAX_BYTES = 50 * 1024
+MAX_FILE_READ_BYTES = 200 * 1024 * 1024
+TRUNCATION_NOTICE_MARKER = "<>"
diff --git a/reme4/steps/__init__.py b/reme4/steps/__init__.py
index fa25273c..70878e5f 100644
--- a/reme4/steps/__init__.py
+++ b/reme4/steps/__init__.py
@@ -1,9 +1,11 @@
"""steps"""
from . import common
+from . import crud
from .base_step import BaseStep
__all__ = [
"common",
+ "crud",
"BaseStep",
]
diff --git a/reme4/steps/base_step.py b/reme4/steps/base_step.py
index d32c4445..f8a4d7aa 100644
--- a/reme4/steps/base_step.py
+++ b/reme4/steps/base_step.py
@@ -2,6 +2,7 @@
import copy
from abc import abstractmethod, ABC
+from pathlib import Path
from typing import TypeVar, TYPE_CHECKING
from agentscope.formatter import FormatterBase
@@ -83,7 +84,20 @@ class BaseStep(ABC):
self.context.apply_mapping(self.output_mapping)
return result
- def _resolve(self, key: str, base_cls: type[T], comp_enum: ComponentEnum, attr: str | None = None) -> T:
+ @property
+ def working_path(self) -> Path:
+ """Resolved working directory from app context or cwd."""
+ if self.app_context is None:
+ return Path.cwd()
+ return Path(self.app_context.app_config.working_dir)
+
+ def _resolve(
+ self,
+ key: str,
+ base_cls: type[T],
+ comp_enum: ComponentEnum,
+ attr: str | None = None,
+ ) -> T:
"""Return a kwargs-supplied instance, or look one up by name in the app registry."""
# 1. Step init kwargs, 2. Runtime context (run_job kwargs), 3. App registry by name.
for source in (self.kwargs, self.context or {}):
diff --git a/reme4/steps/crud/__init__.py b/reme4/steps/crud/__init__.py
new file mode 100644
index 00000000..de9ae9ce
--- /dev/null
+++ b/reme4/steps/crud/__init__.py
@@ -0,0 +1,7 @@
+"""CRUD steps for markdown files under the working_dir."""
+
+from .read import ReadStep
+
+__all__ = [
+ "ReadStep",
+]
diff --git a/reme4/steps/crud/_file_io.py b/reme4/steps/crud/_file_io.py
new file mode 100644
index 00000000..4beaa35d
--- /dev/null
+++ b/reme4/steps/crud/_file_io.py
@@ -0,0 +1,109 @@
+"""Shared filesystem helpers for CRUD steps (path gating, safe read, truncation)."""
+
+from pathlib import Path
+
+import aiofiles
+import aiofiles.os
+
+from ...constants import DEFAULT_MAX_BYTES, MAX_FILE_READ_BYTES, TRUNCATION_NOTICE_MARKER
+from ...utils import get_logger
+
+logger = get_logger()
+
+
+def resolve_path(working_path: Path, raw: str) -> tuple[Path | None, str | None]:
+ """Resolve a relative `path=` argument under self.working_path.
+
+ Rules:
+ - the caller supplies the full relative path under ``self.working_path``;
+ absolute paths are rejected.
+ Returns ``(abs_path, None)`` on success, or ``(None, error_message)`` on failure.
+ Filetype-specific gating (e.g. markdown-only / suffix auto-append) is
+ layered on top by callers — see ``reme4/steps/crud/_file_io.py::gate_md``.
+ """
+ if not raw or not str(raw).strip():
+ return None, "`path` is required"
+ s = str(raw).strip()
+ p = Path(s)
+ if p.is_absolute():
+ logger.info("absolute path detected, recommmending relative paths")
+ return p, None
+ return working_path / p, None
+
+
+def gate_md(target: Path, raw: str) -> tuple[Path | None, str | None]:
+ """Markdown-only gate: auto-append `.md` when no suffix; reject any non-`.md` suffix.
+
+ Layered on top of ``BaseStep.resolve_path`` to keep filetype-specific rules
+ out of the generic path resolver.
+ """
+ if target.suffix == "":
+ return target.with_suffix(".md"), None
+ if target.suffix.lower() != ".md":
+ return None, (f"path {raw!r} is not a markdown file; this command only supports .md files")
+ return target, None
+
+
+async def read_file_safe(file_path, max_bytes: int = MAX_FILE_READ_BYTES) -> str:
+ """Read file with utf-8-sig (BOM-tolerant), fallback to errors='ignore'."""
+ stat = await aiofiles.os.stat(str(file_path))
+ read_size = min(stat.st_size, max_bytes)
+ try:
+ async with aiofiles.open(str(file_path), "r", encoding="utf-8-sig") as f:
+ return await f.read(read_size)
+ except UnicodeDecodeError:
+ async with aiofiles.open(
+ str(file_path),
+ "r",
+ encoding="utf-8-sig",
+ errors="ignore",
+ ) as f:
+ return await f.read(read_size)
+
+
+def truncate_text_output(
+ text: str,
+ *,
+ start_line: int = 1,
+ total_lines: int = 0,
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ file_path: str | None = None,
+ encoding: str = "utf-8",
+) -> str:
+ """Truncate text by bytes preserving line integrity; append a continuation notice.
+
+ See qwenpaw `tools/utils.py` for the same semantics. Returns text unchanged when
+ it fits within max_bytes, when max_bytes <= 0, or when the last line itself
+ exceeds max_bytes (unhandled edge case).
+ """
+ if not text or max_bytes <= 0:
+ return text
+
+ try:
+ text_bytes = text.encode(encoding)
+ if len(text_bytes) <= max_bytes:
+ return text
+
+ truncated = text_bytes[:max_bytes]
+ result = truncated.decode(encoding, errors="ignore")
+ newline_count = result.count("\n")
+ next_line = start_line + max(1, newline_count)
+
+ if next_line <= total_lines:
+ read_from = next_line
+ elif start_line < total_lines:
+ read_from = total_lines
+ else:
+ return result
+
+ notice = (
+ TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated."
+ f"\nThe full content is saved to the file and contains {total_lines} lines in total."
+ f"\nThis excerpt starts at line {start_line} and covers the next {max_bytes} bytes."
+ f"\nIf the current content is not enough, call `read` with file={file_path or ''} "
+ f"start_line={read_from} to read more."
+ )
+ return result + notice
+ except Exception:
+ logger.warning("truncate_text_output failed, returning original text", exc_info=True)
+ return text
diff --git a/reme4/steps/crud/read.py b/reme4/steps/crud/read.py
new file mode 100644
index 00000000..55285efa
--- /dev/null
+++ b/reme4/steps/crud/read.py
@@ -0,0 +1,91 @@
+"""Read a markdown file from the vault, with line-range slicing and byte-truncation."""
+
+from ._file_io import (
+ gate_md,
+ resolve_path,
+ read_file_safe,
+ truncate_text_output,
+)
+from ..base_step import BaseStep
+from ...components import R
+
+
+@R.register("read_step")
+class ReadStep(BaseStep):
+ """Read a markdown file. Optional `start_line`/`end_line` for ranged reads."""
+
+ def _fail(self, message: str, **meta) -> None:
+ assert self.context is not None
+ self.context.response.success = False
+ self.context.response.answer = f"Error: {message}"
+ if meta:
+ self.context.response.metadata.update(meta)
+
+ async def execute(self): # pylint: disable=too-many-return-statements
+ assert self.context is not None
+ raw = str(self.context.get("path") or "")
+ start_line = self.context.get("start_line")
+ end_line = self.context.get("end_line")
+
+ target, err = resolve_path(self.working_path, raw)
+ if err:
+ self._fail(err)
+ return None
+
+ target, err = gate_md(target, raw)
+ if err:
+ self._fail(err)
+ return None
+
+ for label, value in (("start_line", start_line), ("end_line", end_line)):
+ if value is None:
+ continue
+ try:
+ int(value)
+ except (TypeError, ValueError):
+ self._fail(f"{label} must be an integer, got {value!r}")
+ return None
+
+ if not target.exists():
+ self._fail(f"file {target} does not exist", path=str(target))
+ return None
+ if not target.is_file():
+ self._fail(f"path {target} is not a file", path=str(target))
+ return None
+
+ try:
+ content = await read_file_safe(target)
+ except Exception as e:
+ self._fail(f"read failed: {e}", path=str(target))
+ return None
+
+ all_lines = content.split("\n")
+ total = len(all_lines)
+ s = max(1, int(start_line) if start_line is not None else 1)
+ e = min(total, int(end_line) if end_line is not None else total)
+
+ if s > total:
+ self._fail(
+ f"start_line {s} exceeds file length ({total} lines)",
+ path=str(target),
+ total_lines=total,
+ )
+ return None
+ if s > e:
+ self._fail(f"start_line ({s}) > end_line ({e})", path=str(target))
+ return None
+
+ selected = "\n".join(all_lines[s - 1 : e])
+ text = truncate_text_output(
+ selected,
+ start_line=s,
+ total_lines=total,
+ file_path=str(target),
+ )
+
+ self.context.response.success = True
+ self.context.response.answer = text
+ self.logger.info(
+ f"[{self.name}] read path={target} lines={s}-{e}/{total} bytes={len(text.encode('utf-8'))}",
+ )
+ return self.context.response
diff --git a/tests4/unittest/test_crud_md_steps.py b/tests4/unittest/test_crud_md_steps.py
new file mode 100644
index 00000000..d60c72f9
--- /dev/null
+++ b/tests4/unittest/test_crud_md_steps.py
@@ -0,0 +1,371 @@
+"""End-to-end tests for reme4 crud_md steps: spawn `reme4 start`, drive via HTTP,
+verify responses, then shut down. Each test uses an isolated cwd so the working_dir
+(.reme by default) does not collide.
+
+CLI rule: `path=` is relative-only, rooted at the reme working_dir. A bare path with
+no suffix auto-appends `.md`; non-`.md` suffix is rejected. Absolute paths are
+rejected.
+"""
+
+import asyncio
+import os
+import tempfile
+import warnings
+from pathlib import Path
+
+from reme4.utils import call_action, call_and_check, mock_reme_server
+
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
+warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
+
+
+class _temp_chdir:
+ """chdir to path for the duration of the block; restore on exit."""
+
+ def __init__(self, path):
+ self.path = path
+ self._old = None
+
+ def __enter__(self):
+ self._old = os.getcwd()
+ os.chdir(self.path)
+ return self
+
+ def __exit__(self, *exc):
+ os.chdir(self._old)
+
+
+def _run(coro):
+ """Run an async coroutine on a fresh isolated event loop."""
+ asyncio.run(coro)
+
+
+def _seed_md(working_dir: Path, rel: str, body: str) -> Path:
+ target = working_dir / rel
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(body, encoding="utf-8")
+ return target
+
+
+# ---------------------------------------------------------------------------
+# Individual job tests
+# ---------------------------------------------------------------------------
+
+
+def test_read_relative_path():
+ """`reme4 read path=Templates/Recipe.md` returns the file body from .reme/."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ body = "# Recipe\n\nMix flour and water.\n"
+ _seed_md(working, "Templates/Recipe.md", body)
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Templates/Recipe.md",
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and "# Recipe" in str(r.get("answer", ""))
+ and "flour and water" in str(r.get("answer", ""))
+ ),
+ )
+ print("✓ test_read_relative_path passed")
+
+ _run(run())
+
+
+def test_read_no_suffix_autoappends_md():
+ """A bare path with no suffix auto-appends `.md`."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ _seed_md(working, "Templates/Recipe.md", "auto-md\n")
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Templates/Recipe",
+ validator=lambda r: (
+ isinstance(r, dict) and r.get("success") is True and "auto-md" in str(r.get("answer", ""))
+ ),
+ )
+ print("✓ test_read_no_suffix_autoappends_md passed")
+
+ _run(run())
+
+
+def test_read_line_range():
+ """start_line / end_line slice the file 1-based, inclusive."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ _seed_md(working, "Notes.md", "L1\nL2\nL3\nL4\nL5\n")
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Notes.md",
+ start_line=2,
+ end_line=4,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and "L2" in str(r["answer"])
+ and "L3" in str(r["answer"])
+ and "L4" in str(r["answer"])
+ and "L1" not in str(r["answer"])
+ and "L5" not in str(r["answer"])
+ ),
+ )
+ print("✓ test_read_line_range passed")
+
+ _run(run())
+
+
+def test_read_absolute_path_rejected():
+ """Absolute paths are rejected (relative-only after refactor)."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ target = _seed_md(working, "Abs.md", "x\n")
+ async with mock_reme_server() as (host, port):
+ result = await call_action(
+ "read",
+ host=host,
+ port=port,
+ path=str(target.resolve()),
+ )
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "absolute" in str(result.get("answer", "")).lower()
+ ):
+ raise AssertionError(f"expected absolute-path rejection, got {result!r}")
+ print("✓ test_read_absolute_path_rejected passed")
+
+ _run(run())
+
+
+def test_read_non_md_rejected():
+ """Paths whose suffix is not `.md` are rejected."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ async with mock_reme_server() as (host, port):
+ result = await call_action(
+ "read",
+ host=host,
+ port=port,
+ path="data/foo.txt",
+ )
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "markdown" in str(result.get("answer", "")).lower()
+ ):
+ raise AssertionError(f"expected markdown-only rejection, got {result!r}")
+ print("✓ test_read_non_md_rejected passed")
+
+ _run(run())
+
+
+def test_read_missing_file():
+ """Reading a non-existent file should fail with a clear error."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ async with mock_reme_server() as (host, port):
+ result = await call_action(
+ "read",
+ host=host,
+ port=port,
+ path="NotThere.md",
+ )
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "does not exist" in str(result.get("answer", "")).lower()
+ ):
+ raise AssertionError(f"expected missing-file rejection, got {result!r}")
+ print("✓ test_read_missing_file passed")
+
+ _run(run())
+
+
+def test_read_start_after_end():
+ """start_line > end_line is invalid."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ _seed_md(working, "Range.md", "a\nb\nc\n")
+ async with mock_reme_server() as (host, port):
+ result = await call_action(
+ "read",
+ host=host,
+ port=port,
+ path="Range.md",
+ start_line=3,
+ end_line=1,
+ )
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "start_line" in str(result.get("answer", ""))
+ ):
+ raise AssertionError(f"expected start>end rejection, got {result!r}")
+ print("✓ test_read_start_after_end passed")
+
+ _run(run())
+
+
+def test_read_start_line_exceeds_total():
+ """start_line beyond total line count is invalid."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ _seed_md(working, "Short.md", "only-one-line\n")
+ async with mock_reme_server() as (host, port):
+ result = await call_action(
+ "read",
+ host=host,
+ port=port,
+ path="Short.md",
+ start_line=99,
+ )
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "exceeds" in str(result.get("answer", "")).lower()
+ ):
+ raise AssertionError(f"expected exceeds-length rejection, got {result!r}")
+ print("✓ test_read_start_line_exceeds_total passed")
+
+ _run(run())
+
+
+def test_read_truncation():
+ """A small max_bytes triggers truncation with a continuation notice."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ body = "\n".join(f"line {i}" for i in range(200)) + "\n"
+ _seed_md(working, "Big.md", body)
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Big.md",
+ max_bytes=64,
+ validator=lambda r: (
+ isinstance(r, dict)
+ and r.get("success") is True
+ and "truncated" in str(r["answer"])
+ and "start_line=" in str(r["answer"])
+ ),
+ )
+ print("✓ test_read_truncation passed")
+
+ _run(run())
+
+
+def test_read_empty_path_rejected():
+ """An empty `path` should be rejected."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ async with mock_reme_server() as (host, port):
+ result = await call_action("read", host=host, port=port, path="")
+ if not (
+ isinstance(result, dict)
+ and result.get("success") is False
+ and "required" in str(result.get("answer", "")).lower()
+ ):
+ raise AssertionError(f"expected `path` required rejection, got {result!r}")
+ print("✓ test_read_empty_path_rejected passed")
+
+ _run(run())
+
+
+# ---------------------------------------------------------------------------
+# Aggregate test: reuse one server instance for all read cases (faster).
+# ---------------------------------------------------------------------------
+
+
+def test_all_read_cases_one_server():
+ """Run multiple read scenarios against a single shared server for efficiency."""
+
+ async def run():
+ with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
+ working = Path(tmp) / ".reme"
+ working.mkdir(parents=True, exist_ok=True)
+ _seed_md(working, "Templates/Recipe.md", "# Recipe\nbody\n")
+ _seed_md(working, "Notes.md", "L1\nL2\nL3\n")
+ async with mock_reme_server() as (host, port):
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Templates/Recipe.md",
+ validator=lambda r: r.get("success") is True and "# Recipe" in r["answer"],
+ )
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Notes",
+ validator=lambda r: r.get("success") is True and "L1" in r["answer"],
+ )
+ await call_and_check(
+ "read",
+ host=host,
+ port=port,
+ path="Notes.md",
+ start_line=2,
+ end_line=2,
+ validator=lambda r: r.get("success") is True and r["answer"].strip() == "L2",
+ )
+ print("✓ test_all_read_cases_one_server passed")
+
+ _run(run())
+
+
+if __name__ == "__main__":
+ print("\n=== reme4 crud_md (read) E2E tests ===")
+ test_read_relative_path()
+ test_read_no_suffix_autoappends_md()
+ test_read_line_range()
+ test_read_absolute_path_rejected()
+ test_read_non_md_rejected()
+ test_read_missing_file()
+ test_read_start_after_end()
+ test_read_start_line_exceeds_total()
+ test_read_truncation()
+ test_read_empty_path_rejected()
+ test_all_read_cases_one_server()
+ print("\n所有测试通过!")
From 40feaa915099f926e8c821fe15f0cf54987cd028 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Tue, 19 May 2026 16:20:09 +0800
Subject: [PATCH 16/16] fix(core): correct tool results directory naming (#246)
- Changed directory name from 'tool_result' to 'tool_results' in documentation
- Updated path variable assignment to use correct plural form 'tool_results'
- Ensured consistent directory naming throughout initialization logic
---
reme/reme_light.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/reme/reme_light.py b/reme/reme_light.py
index ca52a394..60f0a6c9 100644
--- a/reme/reme_light.py
+++ b/reme/reme_light.py
@@ -119,7 +119,7 @@ class ReMeLight(Application):
The following directory structure will be created:
- {working_dir}/ - Root working directory
- {working_dir}/memory/ - Memory storage files
- - {working_dir}/tool_result/ - Compacted tool result files
+ - {working_dir}/tool_results/ - Compacted tool result files
- {working_dir}/dialog/ - Raw conversation records
"""
# Initialize working directory structure
@@ -127,7 +127,7 @@ class ReMeLight(Application):
self.working_path.mkdir(parents=True, exist_ok=True)
self.memory_path = self.working_path / "memory"
self.memory_path.mkdir(parents=True, exist_ok=True)
- self.tool_result_path = self.working_path / "tool_result"
+ self.tool_result_path = self.working_path / "tool_results"
self.tool_result_path.mkdir(parents=True, exist_ok=True)
self.dialog_path = self.working_path / "dialog"
self.dialog_path.mkdir(parents=True, exist_ok=True)