mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-22 00:32:49 +00:00
docs(protocol): add typed edge link protocol documentation Add comprehensive documentation for the link protocol supporting typed edges in body text. This includes specification for three legal inline forms (bare wikilink, line-level Dataview, inline-bracketed Dataview), predicate syntax rules, and the machine-managed Relations section convention for organizing discovered edges. fix(memory): update path reference from vault_root to working_dir Change the memory_create operation's path anchoring from vault_root to working_dir to maintain consistency with the current working directory configuration. refactor(components): remove edge_extractor module and simplify parsing Remove the edge_extractor component module entirely and inline edge extraction logic directly into LinkedFileParser using parse_wikilinks utility. This simplifies the architecture by eliminating the separate edge extraction component and delegating edge discovery to the maintainer's enrichment operations. feat(parser): update parse method signature and simplify edge extraction Modify LinkedFileParser to return (FileNode, list[FileChunk]) tuple instead of ParsedFile, remove dependency on BaseEdgeExtractor, and implement direct wikilink parsing from body text only. ```
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_store.default.working_dir={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
|