mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-12 08:45:15 +00:00
feat(file_watcher): add directory deletion support with descendant indexing Add support for deleting entire directories and their indexed descendants in the file watcher. Previously only individual file deletions were handled properly. Now when a directory is deleted, the system finds all indexed files beneath that directory path and removes them along with their metadata and chunks. The implementation includes: - New `_descendant_indexed_paths` method to find all indexed files under a given directory path - Updated `_on_deleted` method to process both the target path and all its indexed descendants - Proper handling of symlinks and path resolution differences - Enhanced logging to show directory deletion with child count Also adds necessary os import for path operations. refactor(config): restructure configuration profiles for clarity Rename curated.yaml to remove outdated configuration file and rename full.yaml to expert.yaml with updated documentation. Add new service.yaml configuration profile that provides a service-aligned MCP surface with three main tools: - retrieve: graph-aware hybrid retrieval - remember: single write entry point with log/distill modes - maintain: vault hygiene sweep The expert configuration now excludes the ingest tool since cold-path operations are handled by external agents, and adds memory_lint tool for structural issue detection. Updated documentation to clarify the different configuration profiles and their intended usage patterns. ```
162 lines
5.5 KiB
Python
162 lines
5.5 KiB
Python
"""Shared fixtures for the MCP profile tests.
|
|
|
|
Each suite uses one app per profile (boot cost ~2s) and a fresh temp
|
|
vault. The app is configured with `service.backend = http` so the
|
|
stdio MCP listener never starts — jobs are invoked directly via
|
|
`app.run_job`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
# Make the repo importable when this module is loaded standalone
|
|
# (`python -m reme2.mcp.test` already has the path; direct imports
|
|
# from a fresh interpreter need this guard).
|
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
# Eager-import side-effect modules so all @R.register() decorators run
|
|
# before Application introspects the registry.
|
|
import reme2 # noqa: E402,F401
|
|
import reme2.mcp.steps # noqa: E402,F401
|
|
import reme2.memory # noqa: E402,F401 -- registers retriever + maintainer
|
|
import reme2.memory.ingestor # noqa: E402,F401
|
|
import reme2.memory.summarizer # noqa: E402,F401
|
|
|
|
from reme2.application import Application # noqa: E402
|
|
from reme2.config import parse_args # noqa: E402
|
|
|
|
|
|
CONFIG_DIR = _REPO_ROOT / "reme2" / "config"
|
|
PROFILES = {
|
|
"expert": CONFIG_DIR / "expert.yaml",
|
|
"service": CONFIG_DIR / "service.yaml",
|
|
}
|
|
|
|
# How many seed files `seed_vault` writes — checks that need to wait
|
|
# for indexing budget against this baseline.
|
|
SEED_FILE_COUNT = 3
|
|
|
|
|
|
@dataclass
|
|
class AppContext:
|
|
"""Bundle of everything a test function needs."""
|
|
|
|
app: Application
|
|
vault: Path
|
|
jobs: list[str]
|
|
|
|
@property
|
|
def watcher(self):
|
|
return self.app.context.components["file_watcher"]["default"]
|
|
|
|
@property
|
|
def file_store(self):
|
|
return self.watcher.file_store
|
|
|
|
def abs_path(self, *parts: str) -> str:
|
|
return str((self.vault.joinpath(*parts)).resolve())
|
|
|
|
|
|
def seed_vault(vault: Path) -> None:
|
|
"""Drop a small connected topic graph (Alice ↔ Bob ↔ Project X)."""
|
|
(vault / "topics" / "Alice").mkdir(parents=True, exist_ok=True)
|
|
(vault / "topics" / "Alice" / "Alice.md").write_text(
|
|
"---\ntitle: Alice\ncategory: profile\ntags: [person]\n---\n"
|
|
"# Alice\n\nAlice works on [[Project X]] with [[Bob]].\n"
|
|
"[author:: [[Alice]]]\n",
|
|
encoding="utf-8",
|
|
)
|
|
(vault / "topics" / "Bob").mkdir(parents=True, exist_ok=True)
|
|
(vault / "topics" / "Bob" / "Bob.md").write_text(
|
|
"---\ntitle: Bob\ncategory: profile\ntags: [person]\n---\n"
|
|
"# Bob\n\nBob collaborates with [[Alice]] on [[Project X]].\n",
|
|
encoding="utf-8",
|
|
)
|
|
(vault / "topics" / "Project X").mkdir(parents=True, exist_ok=True)
|
|
(vault / "topics" / "Project X" / "Project X.md").write_text(
|
|
"---\ntitle: Project X\ncategory: concept\n---\n"
|
|
"# Project X\n\nA major initiative led by [[Alice]] with [[Bob]].\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
async def wait_for_index(watcher, expected_min: int, timeout_s: float = 15.0) -> None:
|
|
"""Poll `len(file_store)` until it reaches `expected_min` and stays
|
|
stable across 4 ticks. Raises if the budget runs out — a watcher
|
|
that never indexed seed files is a hard wiring failure, not a
|
|
soft check."""
|
|
deadline = asyncio.get_event_loop().time() + timeout_s
|
|
last = -1
|
|
stable = 0
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
now = len(watcher.file_store)
|
|
if now == last and now >= expected_min:
|
|
stable += 1
|
|
if stable >= 4:
|
|
return
|
|
else:
|
|
stable = 0
|
|
last = now
|
|
await asyncio.sleep(0.25)
|
|
raise RuntimeError(
|
|
f"watcher did not reach >= {expected_min} files within {timeout_s}s "
|
|
f"(last seen: {last})"
|
|
)
|
|
|
|
|
|
def decode(resp) -> object:
|
|
"""Job answers come back as JSON strings — decode for inspection.
|
|
Pass through dicts / lists if the step already returned native types."""
|
|
if isinstance(resp.answer, (dict, list)):
|
|
return resp.answer
|
|
if isinstance(resp.answer, str):
|
|
try:
|
|
return json.loads(resp.answer)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return resp.answer
|
|
return resp.answer
|
|
|
|
|
|
async def build_app(profile_path: Path, vault: Path) -> Application:
|
|
"""Boot an Application against `vault` using the given profile.
|
|
|
|
Forces `service.backend = http` so the stdio MCP listener never
|
|
starts (we only call jobs directly), and rewrites the watcher /
|
|
file_store paths to point at the temp vault.
|
|
"""
|
|
_, cfg = parse_args(
|
|
"start",
|
|
f"config={profile_path}",
|
|
f"components.file_watcher.default.watch_path={vault}",
|
|
f"components.file_store.default.db_path={vault}/.reme",
|
|
)
|
|
cfg["service"] = {"backend": "http"}
|
|
app = Application(**cfg)
|
|
await app.start()
|
|
return app
|
|
|
|
|
|
async def make_context(profile: str) -> tuple[AppContext, Path]:
|
|
"""Build a fresh temp vault + app for `profile`, wait for indexing.
|
|
|
|
The caller owns the temp dir lifecycle — returns its Path so it can
|
|
be `shutil.rmtree`d after `app.close()`.
|
|
"""
|
|
import tempfile
|
|
|
|
tmp = Path(tempfile.mkdtemp(prefix=f"reme2-mcp-{profile}-"))
|
|
vault = tmp / "vault"
|
|
vault.mkdir()
|
|
seed_vault(vault)
|
|
app = await build_app(PROFILES[profile], vault)
|
|
await wait_for_index(app.context.components["file_watcher"]["default"],
|
|
expected_min=SEED_FILE_COUNT)
|
|
jobs = sorted(app.context.jobs.keys())
|
|
return AppContext(app=app, vault=vault, jobs=jobs), tmp
|