diff --git a/.gitignore b/.gitignore index fa4adba4..81830405 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ meta_memory/* *.db memories/* .reme/* -/test_data \ No newline at end of file +/vault +/reme-plugin +/reme2/vault \ No newline at end of file diff --git a/reme/memory/vector_based/personal/personal_retriever.py b/reme/memory/vector_based/personal/personal_retriever.py index 2b0c094e..b8803717 100644 --- a/reme/memory/vector_based/personal/personal_retriever.py +++ b/reme/memory/vector_based/personal/personal_retriever.py @@ -106,5 +106,5 @@ class PersonalRetriever(BaseMemoryAgent): ], ) - result["retrieved_nodes"] = self.retri eved_nodes + result["retrieved_nodes"] = self.retrieved_nodes return result diff --git a/reme2/application.py b/reme2/application.py index 89c4f667..10f6ed86 100644 --- a/reme2/application.py +++ b/reme2/application.py @@ -114,14 +114,14 @@ class Application(BaseComponent): except Exception as e: self.logger.exception(f"Failed to close component {component.__class__.__name__}: {e}") - async def run_job(self, name: str, **kwargs) -> Response: + 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") job = self.context.jobs[name] return await job(**kwargs) - async def run_stream_job(self, name: str, **kwargs) -> AsyncGenerator[StreamChunk, None]: + 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") @@ -129,10 +129,10 @@ class Application(BaseComponent): 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", + stream_queue=stream_queue, + task=task, + task_name=name, + output_format="chunk", ): assert isinstance(chunk, StreamChunk) yield chunk diff --git a/reme2/component/__init__.py b/reme2/component/__init__.py index 6b3942ec..80e89d70 100644 --- a/reme2/component/__init__.py +++ b/reme2/component/__init__.py @@ -2,7 +2,9 @@ from . import as_llm from . import as_llm_formatter -from . import chunk_store +from . import as_token_counter +from . import edge_extractor +from . import file_store from . import client from . import embedding from . import file_parser @@ -27,7 +29,9 @@ __all__ = [ # base components "as_llm", "as_llm_formatter", - "chunk_store", + "as_token_counter", + "edge_extractor", + "file_store", "client", "embedding", "file_parser", diff --git a/reme2/component/as_llm_formatter/reme_openai_chat_formatter.py b/reme2/component/as_llm_formatter/reme_openai_chat_formatter.py index 66dbfd51..a501da26 100644 --- a/reme2/component/as_llm_formatter/reme_openai_chat_formatter.py +++ b/reme2/component/as_llm_formatter/reme_openai_chat_formatter.py @@ -35,8 +35,8 @@ class ReMeOpenAIChatFormatter(OpenAIChatFormatter): """Extends OpenAIChatFormatter with tool result image promotion and reasoning content support.""" async def _format( - self, - msgs: list[Msg], + self, + msgs: list[Msg], ) -> list[dict[str, Any]]: """Format messages into OpenAI API format. @@ -108,7 +108,7 @@ class ReMeOpenAIChatFormatter(OpenAIChatFormatter): TextBlock( type="text", text="The following are the image contents from the tool " - f"result of '{block['name']}':", + f"result of '{block['name']}':", ), *promoted_blocks, TextBlock(type="text", text=""), diff --git a/reme2/component/as_token_counter/estimate_token_counter.py b/reme2/component/as_token_counter/estimate_token_counter.py index 01f106c7..eca3f446 100644 --- a/reme2/component/as_token_counter/estimate_token_counter.py +++ b/reme2/component/as_token_counter/estimate_token_counter.py @@ -12,9 +12,9 @@ class EstimatedTokenCounter(TokenCounterBase): """ def __init__( - self, - estimate_divisor: float = 4, - encoding: str = "utf-8", + self, + estimate_divisor: float = 4, + encoding: str = "utf-8", ): """Initialize the estimated token counter. diff --git a/reme2/component/base_component.py b/reme2/component/base_component.py index 0d5c1e9a..4cab9f58 100644 --- a/reme2/component/base_component.py +++ b/reme2/component/base_component.py @@ -20,11 +20,11 @@ class BaseComponent(ABC): component_type = ComponentEnum.BASE def __init__( - self, - name: str | None = None, - backend: str = "", - app_context: "ApplicationContext | None" = None, - **kwargs, + 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 diff --git a/reme2/component/base_step.py b/reme2/component/base_step.py index 52cb699e..e149fa4a 100644 --- a/reme2/component/base_step.py +++ b/reme2/component/base_step.py @@ -8,12 +8,11 @@ from agentscope.model import ChatModelBase from agentscope.token import TokenCounterBase from .base_component import BaseComponent -from .chunk_store import BaseChunkStore +from .file_store import BaseFileStore from .embedding import BaseEmbeddingModel from .prompt_handler import PromptHandler from .runtime_context import RuntimeContext from ..enumeration import ComponentEnum -from ..schema.file_graph import FileGraph class BaseStep(BaseComponent): @@ -28,12 +27,12 @@ class BaseStep(BaseComponent): return instance def __init__( - self, - language: str = "", - prompt_dict: dict[str, str] | None = None, - input_mapping: dict[str, str] | None = None, - output_mapping: dict[str, str] | None = None, - **kwargs, + self, + 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__(**kwargs) self.language = language @@ -66,6 +65,17 @@ class BaseStep(BaseComponent): comp = self.app_context.components[key][name] return getattr(comp, attr) if attr else comp + def _get_component_optional(self, key: ComponentEnum, name: str = "default", attr: str | None = None): + """Like `_get_component` but returns None instead of raising when + the component / attribute is missing. For features that should + gracefully degrade when an LLM (etc.) isn't configured.""" + if self.app_context is None: + return None + comp = self.app_context.components.get(key, {}).get(name) + if comp is None: + return None + return getattr(comp, attr, None) if attr else comp + @property def as_llm(self) -> ChatModelBase: name = self.kwargs.get("as_llm", "default") @@ -74,31 +84,45 @@ class BaseStep(BaseComponent): @property def as_llm_formatter(self) -> FormatterBase: name = self.kwargs.get("as_llm_formatter", "default") - return name if isinstance(name, FormatterBase) else self._get_component(ComponentEnum.AS_LLM_FORMATTER, name, - "formatter") + return ( + name + if isinstance(name, FormatterBase) + else self._get_component( + ComponentEnum.AS_LLM_FORMATTER, + name, + "formatter", + ) + ) @property def as_token_counter(self) -> TokenCounterBase: name = self.kwargs.get("as_token_counter", "default") - return name if isinstance(name, TokenCounterBase) else self._get_component(ComponentEnum.AS_TOKEN_COUNTER, name, - "token_counter") + return ( + name + if isinstance(name, TokenCounterBase) + else self._get_component( + ComponentEnum.AS_TOKEN_COUNTER, + name, + "token_counter", + ) + ) @property - def chunk_store(self) -> BaseChunkStore: - name = self.kwargs.get("chunk_store", "default") - return name if isinstance(name, BaseChunkStore) else self._get_component(ComponentEnum.CHUNK_STORE, name) - - @property - def file_graph(self) -> FileGraph: - name = self.kwargs.get("file_watcher", "default") - watcher = self._get_component(ComponentEnum.FILE_WATCHER, name) - return watcher.file_graph + def file_store(self) -> BaseFileStore: + name = self.kwargs.get("file_store", "default") + return name if isinstance(name, BaseFileStore) else self._get_component(ComponentEnum.FILE_STORE, name) @property def embedding(self) -> BaseEmbeddingModel: name = self.kwargs.get("embedding", "default") - return name if isinstance(name, BaseEmbeddingModel) else self._get_component(ComponentEnum.EMBEDDING_MODEL, - name) + return ( + name + if isinstance(name, BaseEmbeddingModel) + else self._get_component( + ComponentEnum.EMBEDDING_MODEL, + name, + ) + ) def prompt_format(self, prompt_name: str, **kwargs) -> str: return self.prompt.prompt_format(prompt_name=prompt_name, **kwargs) diff --git a/reme2/component/chunk_store/__init__.py b/reme2/component/chunk_store/__init__.py deleted file mode 100644 index 0eb1470f..00000000 --- a/reme2/component/chunk_store/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Chunk store module. - -Storage backends for FileChunks with vector and full-text search. -File metadata is managed by FileGraph, not by ChunkStore. -""" - -from .base_chunk_store import BaseChunkStore -from .chroma_chunk_store import ChromaChunkStore -from .local_chunk_store import LocalChunkStore -from .sqlite_chunk_store import SqliteChunkStore - -__all__ = [ - "BaseChunkStore", - "ChromaChunkStore", - "LocalChunkStore", - "SqliteChunkStore", -] diff --git a/reme2/component/chunk_store/base_chunk_store.py b/reme2/component/chunk_store/base_chunk_store.py deleted file mode 100644 index 834584da..00000000 --- a/reme2/component/chunk_store/base_chunk_store.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Abstract base class for chunk storage backends.""" - -import re -from abc import abstractmethod -from pathlib import Path - -from ..base_component import BaseComponent -from ..embedding import BaseEmbeddingModel -from ...enumeration import ComponentEnum -from ...schema import ChunkFilter, FileChunk - - -class BaseChunkStore(BaseComponent): - """Abstract base class for chunk storage backends. - - Handles chunk persistence and retrieval (vector / keyword / hybrid search). - File-level metadata and search filter resolution live in FileGraph; this - layer only consumes a compiled ChunkFilter (path set) for restricting search. - """ - - component_type = ComponentEnum.CHUNK_STORE - - def __init__( - self, - store_name: str, - db_path: str | Path, - embedding_model: str = "default", - fts_enabled: bool = True, - **kwargs, - ): - super().__init__(**kwargs) - self._embedding_model_name: str = embedding_model - self.embedding_model: BaseEmbeddingModel | None = None - self.store_name: str = store_name - self.db_path: Path = Path(db_path) - self.db_path.mkdir(parents=True, exist_ok=True) - self.vector_enabled: bool = bool(embedding_model) - self.fts_enabled: bool = fts_enabled - - if not re.match(r"^[a-zA-Z0-9_]+$", store_name): - raise ValueError( - f"Invalid store name '{store_name}'. Only alphanumeric characters and underscores are allowed.", - ) - if not self.vector_enabled and not self.fts_enabled: - raise ValueError("At least one of embedding_model or fts_enabled must be set.") - - async def _start(self): - if not self._embedding_model_name: - return - assert self.app_context is not None, "app_context must be provided" - models = self.app_context.components.get(ComponentEnum.EMBEDDING_MODEL, {}) - if self._embedding_model_name not in models: - raise ValueError(f"Embedding model '{self._embedding_model_name}' not found.") - model = models[self._embedding_model_name] - if not isinstance(model, BaseEmbeddingModel): - raise TypeError(f"Expected BaseEmbeddingModel, got {type(model).__name__}") - self.embedding_model = model - - async def _close(self): - """Release embedding model reference.""" - self.embedding_model = None - - @property - def embedding_dim(self) -> int: - """Return the embedding dimensionality (default 1024).""" - return self.embedding_model.dimensions if self.embedding_model else 1024 - - def _disable_vector_search(self, reason: str = "embedding API error") -> None: - """Disable vector search and log a warning.""" - if self.vector_enabled: - self.logger.warning(f"[{self.store_name}] Disabling vector search: {reason}") - self.vector_enabled = False - - async def _get_embeddings_safe(self, texts: list[str], **kwargs) -> list[list[float]] | None: - """Get embeddings, returning None if vector search is disabled or an error occurs.""" - if not self.vector_enabled: - return None - try: - assert self.embedding_model is not None, "Embedding model not initialized" - return await self.embedding_model.get_embeddings(texts, **kwargs) - except Exception as e: - self._disable_vector_search(str(e)) - return None - - async def get_embedding(self, query: str, **kwargs) -> list[float] | None: - """Get embedding for a single query string.""" - result = await self._get_embeddings_safe([query], **kwargs) - return result[0] if result else None - - async def get_embeddings(self, queries: list[str], **kwargs) -> list[list[float]] | None: - """Get embeddings for a batch of query strings.""" - return await self._get_embeddings_safe(queries, **kwargs) - - async def get_chunk_embedding(self, chunk: FileChunk, **kwargs) -> FileChunk: - """Attach embedding to a single FileChunk.""" - chunk.embedding = await self.get_embedding(chunk.text, **kwargs) - return chunk - - async def get_chunk_embeddings(self, chunks: list[FileChunk], **kwargs) -> list[FileChunk]: - """Attach embeddings to a batch of FileChunk.""" - if not chunks: - return chunks - embeddings = await self.get_embeddings([c.text for c in chunks], **kwargs) - if embeddings and len(embeddings) == len(chunks): - for chunk, emb in zip(chunks, embeddings): - chunk.embedding = emb - else: - for chunk in chunks: - chunk.embedding = None - return chunks - - # -- Keyword scoring utility -------------------------------------------- - - @staticmethod - def _score_keyword_match(query: str, text: str) -> float: - """Score a keyword match using word-match ratio + phrase bonus.""" - words = query.split() - if not words: - return 0.0 - query_lower = query.lower() - words_lower = [w.lower() for w in words] - text_lower = text.lower() - n_words = len(words) - - match_count = sum(1 for w in words_lower if w in text_lower) - if match_count == 0: - return 0.0 - - base_score = match_count / n_words - phrase_bonus = 0.2 if n_words > 1 and query_lower in text_lower else 0.0 - return min(1.0, base_score + phrase_bonus) - - # -- Hybrid search (concrete, delegates to abstract vector/keyword) ----- - - async def hybrid_search( - self, - query: str, - limit: int, - vector_weight: float = 0.7, - candidate_multiplier: float = 3.0, - chunk_filter: ChunkFilter | None = None, - ) -> list[FileChunk]: - """Perform hybrid search combining vector and keyword results.""" - assert 0.0 <= vector_weight <= 1.0 - - 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, chunk_filter) - vector_results = await self.vector_search(query, candidates, chunk_filter) - - if not keyword_results: - return vector_results[:limit] - if not vector_results: - return keyword_results[:limit] - - merged = self._merge_hybrid_results( - vector_results, - keyword_results, - vector_weight, - text_weight, - ) - return merged[:limit] - elif self.vector_enabled: - return await self.vector_search(query, limit, chunk_filter) - elif self.fts_enabled: - return await self.keyword_search(query, limit, chunk_filter) - return [] - - @staticmethod - def _merge_hybrid_results( - vector: list[FileChunk], - keyword: list[FileChunk], - vector_weight: float, - text_weight: float, - ) -> list[FileChunk]: - """Merge vector and keyword results with weighted scoring.""" - merged: dict[str, FileChunk] = {} - - for result in vector: - v_score = result.scores.get("vector", 0) - result.scores["score"] = v_score * vector_weight - merged[result.unique_key] = result - - for result in keyword: - key = result.unique_key - k_score = result.scores.get("keyword", 0) - if key in merged: - merged[key].scores["score"] += k_score * text_weight - else: - result.scores["score"] = k_score * text_weight - merged[key] = result - - results = list(merged.values()) - results.sort(key=lambda r: r.score, reverse=True) - return results - - # -- Filter utility ----------------------------------------------------- - - @staticmethod - def _apply_filter(chunks: list[FileChunk], chunk_filter: ChunkFilter | None) -> list[FileChunk]: - if chunk_filter is None or chunk_filter.resolved_paths is None: - return chunks - return [c for c in chunks if chunk_filter.match_path(c.path)] - - # -- Abstract methods --------------------------------------------------- - - @abstractmethod - async def clear_all(self): - """Clear all indexed data.""" - - @abstractmethod - async def upsert_chunks(self, path: str, chunks: list[FileChunk]): - """Insert or update all chunks for a file path.""" - - @abstractmethod - async def delete_chunks(self, path: str): - """Delete all chunks for a file path.""" - - @abstractmethod - async def get_chunks(self, path: str) -> list[FileChunk]: - """Get all chunks for a file path.""" - - @abstractmethod - async def vector_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, - ) -> list[FileChunk]: - """Perform vector similarity search, optionally restricted by chunk_filter.""" - - @abstractmethod - async def keyword_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, - ) -> list[FileChunk]: - """Perform full-text/keyword search, optionally restricted by chunk_filter.""" diff --git a/reme2/component/chunk_store/chroma_chunk_store.py b/reme2/component/chunk_store/chroma_chunk_store.py deleted file mode 100644 index 44c17966..00000000 --- a/reme2/component/chunk_store/chroma_chunk_store.py +++ /dev/null @@ -1,226 +0,0 @@ -"""ChromaDB chunk storage backend.""" - -import time - -from .base_chunk_store import BaseChunkStore -from ..component_registry import R -from ...schema import ChunkFilter, FileChunk - -try: - import chromadb - from chromadb.config import Settings - - _CHROMADB_IMPORT_ERROR: Exception | None = None -except Exception as e: - _CHROMADB_IMPORT_ERROR = e - chromadb = None - Settings = None - - -@R.register("chroma") -class ChromaChunkStore(BaseChunkStore): - """ChromaDB chunk storage with vector and full-text search. - - Uses ChromaDB's native vector search and `where_document` $contains - for keyword matching. - """ - - def __init__(self, **kwargs): - if _CHROMADB_IMPORT_ERROR is not None: - raise _CHROMADB_IMPORT_ERROR - super().__init__(**kwargs) - self.client: "chromadb.ClientAPI | None" = None - self.chunks_collection: "chromadb.Collection | None" = None - - @property - def collection_name(self) -> str: - return f"chunks_{self.store_name}" - - # -- Lifecycle ---------------------------------------------------------- - - async def _start(self, app_context=None) -> None: - self.client = chromadb.PersistentClient( - path=str(self.db_path), - settings=Settings(allow_reset=True, anonymized_telemetry=False), - ) - self.chunks_collection = self.client.get_or_create_collection( - name=self.collection_name, - metadata={"hnsw:space": "cosine"}, - ) - self.logger.info(f"ChromaChunkStore '{self.store_name}' ready: collection={self.collection_name}") - await super()._start(app_context) - - async def _close(self) -> None: - self.client = None - self.chunks_collection = None - await super()._close() - - # -- Filter helper ------------------------------------------------------ - - @staticmethod - def _path_where(chunk_filter: ChunkFilter | None) -> dict | None: - if chunk_filter is None or chunk_filter.resolved_paths is None: - return None - paths = chunk_filter.resolved_paths - if not paths: - return {"path": "__nonexistent__"} - if len(paths) == 1: - return {"path": next(iter(paths))} - return {"path": {"$in": list(paths)}} - - # -- Write operations --------------------------------------------------- - - async def upsert_chunks(self, path: str, chunks: list[FileChunk]) -> None: - await self.delete_chunks(path) - - if not chunks: - return - - chunks = await self.get_chunk_embeddings(chunks) - - ids, documents, embeddings, metadatas = [], [], [], [] - now = int(time.time() * 1000) - for chunk in chunks: - ids.append(chunk.id) - documents.append(chunk.text) - embeddings.append(chunk.embedding if chunk.embedding else [0.0] * self.embedding_dim) - metadatas.append({ - "path": path, - "start_line": chunk.start_line, - "end_line": chunk.end_line, - "hash": chunk.hash, - "updated_at": now, - }) - - self.chunks_collection.upsert( - ids=ids, - documents=documents, - embeddings=embeddings, - metadatas=metadatas, - ) - - async def delete_chunks(self, path: str) -> None: - results = self.chunks_collection.get(where={"path": path}, include=[]) - if results["ids"]: - self.chunks_collection.delete(ids=results["ids"]) - - # -- Read operations ---------------------------------------------------- - - async def get_chunks(self, path: str) -> list[FileChunk]: - results = self.chunks_collection.get(where={"path": path}, include=["documents", "metadatas"]) - chunks: list[FileChunk] = [] - for cid, md, text in zip(results["ids"], results["metadatas"], results["documents"]): - chunks.append(self._chunk_from_chroma(cid, md, text)) - chunks.sort(key=lambda c: c.start_line) - return chunks - - # -- Search helpers ----------------------------------------------------- - - @staticmethod - def _chunk_from_chroma(chunk_id: str, md: dict, text: str, embedding=None) -> FileChunk: - return FileChunk( - id=chunk_id, - path=md["path"], - start_line=md["start_line"], - end_line=md["end_line"], - text=text, - hash=md["hash"], - embedding=embedding, - ) - - # -- Search operations -------------------------------------------------- - - async def vector_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, - ) -> list[FileChunk]: - if not self.vector_enabled or not query: - return [] - - query_embedding = await self.get_embedding(query) - if not query_embedding: - return [] - - try: - results = self.chunks_collection.query( - query_embeddings=[query_embedding], - n_results=limit, - where=self._path_where(chunk_filter), - include=["documents", "metadatas", "distances"], - ) - except Exception as e: - self.logger.error(f"Vector search failed: {e}") - return [] - - chunks = [] - if results["ids"] and results["ids"][0]: - for i, cid in enumerate(results["ids"][0]): - md = results["metadatas"][0][i] - distance = results["distances"][0][i] - score = max(0.0, 1.0 - distance / 2.0) - - chunk = self._chunk_from_chroma(cid, md, results["documents"][0][i]) - chunk.scores = {"vector": score, "score": score} - chunks.append(chunk) - - chunks.sort(key=lambda c: c.score, reverse=True) - return chunks[:limit] - - async def keyword_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, - ) -> list[FileChunk]: - if not self.fts_enabled or not query: - return [] - - words = query.split() - if not words: - return [] - - word_variants = set() - for word in words: - word_variants.add(word) - word_variants.add(word.lower()) - word_variants.add(word.capitalize()) - word_variants.add(word.upper()) - variants_list = list(word_variants) - - if len(variants_list) == 1: - where_document: dict = {"$contains": variants_list[0]} - else: - where_document = {"$or": [{"$contains": w} for w in variants_list]} - - results = self.chunks_collection.get( - where=self._path_where(chunk_filter), - where_document=where_document, - include=["documents", "metadatas"], - ) - - chunks = [] - for i, cid in enumerate(results["ids"]): - md = results["metadatas"][i] - text = results["documents"][i] - score = self._score_keyword_match(query, text) - if score == 0.0: - continue - - chunk = self._chunk_from_chroma(cid, md, text) - chunk.scores = {"keyword": score, "score": score} - chunks.append(chunk) - - chunks.sort(key=lambda c: c.score, reverse=True) - return chunks[:limit] - - # -- Clear -------------------------------------------------------------- - - async def clear_all(self) -> None: - self.client.delete_collection(name=self.collection_name) - self.chunks_collection = self.client.get_or_create_collection( - name=self.collection_name, - metadata={"hnsw:space": "cosine"}, - ) - self.logger.info(f"Cleared all data from ChromaChunkStore '{self.store_name}'") diff --git a/reme2/component/client/http_client.py b/reme2/component/client/http_client.py index c562bf11..0ac423ab 100644 --- a/reme2/component/client/http_client.py +++ b/reme2/component/client/http_client.py @@ -15,12 +15,12 @@ class HttpClient(BaseClient): """HTTP client for ReMe service.""" def __init__( - self, - action: str, - host: str | None = None, - port: int | None = None, - timeout: float = 30.0, - **kwargs, + self, + action: str, + host: str | None = None, + port: int | None = None, + timeout: float = 30.0, + **kwargs, ): super().__init__(**kwargs) diff --git a/reme2/component/component_registry.py b/reme2/component/component_registry.py index 16ead1e0..f8cb85e6 100644 --- a/reme2/component/component_registry.py +++ b/reme2/component/component_registry.py @@ -38,9 +38,9 @@ class ComponentRegistry: return cls def register( - self, - cls_or_name: type[T] | str, - name: str | None = None, + self, + cls_or_name: type[T] | str, + name: str | None = None, ) -> Callable[[type[T]], type[T]] | type[T]: """Register a component class. Supports direct and decorator modes.""" # Direct registration: R.register(MyClass, "name") diff --git a/reme2/component/edge_extractor/__init__.py b/reme2/component/edge_extractor/__init__.py new file mode 100644 index 00000000..021746c8 --- /dev/null +++ b/reme2/component/edge_extractor/__init__.py @@ -0,0 +1,11 @@ +"""Typed-edge extractors — `ComponentEnum.EDGE_EXTRACTOR`.""" + +from .base_edge_extractor import BaseEdgeExtractor +from .llm_edge_extractor import LLMEdgeExtractor +from .regex_edge_extractor import RegexEdgeExtractor + +__all__ = [ + "BaseEdgeExtractor", + "RegexEdgeExtractor", + "LLMEdgeExtractor", +] diff --git a/reme2/component/edge_extractor/base_edge_extractor.py b/reme2/component/edge_extractor/base_edge_extractor.py new file mode 100644 index 00000000..110021bc --- /dev/null +++ b/reme2/component/edge_extractor/base_edge_extractor.py @@ -0,0 +1,49 @@ +"""Abstract base class for typed-edge extractors. + +A `BaseEdgeExtractor` ingests a markdown file's body text + frontmatter +dict and returns a flat list of `FileEdge` objects. The concrete +implementations realize the two routes described in `structure.md` +§"图谱关系的双态抽取": + + Fast route (regex) — RegexEdgeExtractor + Slow route (LLM IE) — LLMEdgeExtractor + +Edges are returned with the `source` field already populated so +downstream consumers (file_store indexing, retrievers, maintainer) can +reason about provenance. + +Resolution to absolute paths and dedup against already-indexed edges +are NOT this layer's responsibility — `target` stays raw, mirroring +how it appears in source. +""" + +from __future__ import annotations + +from abc import abstractmethod + +from ..base_component import BaseComponent +from ...enumeration import ComponentEnum +from ...schema import FileEdge + + +class BaseEdgeExtractor(BaseComponent): + """Pluggable typed-edge extractor (`ComponentEnum.EDGE_EXTRACTOR`).""" + + component_type = ComponentEnum.EDGE_EXTRACTOR + + @abstractmethod + async def extract( + self, + text: str, + metadata: dict | None = None, + path: str | None = None, + ) -> list[FileEdge]: + """Return the edges discovered in `text` and `metadata`. + + Args: + text: The body text of the source file (frontmatter stripped). + metadata: Parsed YAML frontmatter dict, or None. + path: Optional absolute path of the source file. Slow-route + extractors use this for logging / source-text caching; + fast-route ignores it. + """ diff --git a/reme2/component/edge_extractor/llm_edge_extractor.py b/reme2/component/edge_extractor/llm_edge_extractor.py new file mode 100644 index 00000000..30e1e4d3 --- /dev/null +++ b/reme2/component/edge_extractor/llm_edge_extractor.py @@ -0,0 +1,391 @@ +"""LLMEdgeExtractor — slow route per `structure.md` §"双态抽取". + +ReAct agent over the memory store: instead of a single-shot extraction +from the file's text alone, the agent browses the vault via read-only +memory tools (`memory_get` / `memory_list` / `memory_links` / +`memory_backlinks` / `memory_resolve_wikilink`) to ground its triples +in entities that already exist. It finalizes by calling `emit_edges` +with the JSON triples it wants to commit. + +Each accepted triple becomes a `FileEdge` with `source="llm"` and +`confidence` populated from the model's self-rating. + +The extractor does NOT write back to the source file — that's the +caller's job (typically the parser pipeline). It also does NOT +entity-resolve targets; the raw `object` string from the model lands +in `FileEdge.target` and is matched at the file_store layer via the +usual wikilink resolution pipeline. + +Inputs that exceed `max_input_chars` are truncated; long files +should be chunked by the caller before invoking this extractor. + +Triples are filtered by `min_confidence` and predicate-normalized +(lowercase, non-identifier characters → underscore) when +`predicate_normalizer=True`. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import frontmatter +from agentscope.agent import ReActAgent +from agentscope.formatter import FormatterBase +from agentscope.message import Msg, TextBlock +from agentscope.model import ChatModelBase +from agentscope.tool import Toolkit, ToolResponse +from pydantic import BaseModel, Field + +from .base_edge_extractor import BaseEdgeExtractor +from ..component_registry import R +from ..file_store import BaseFileStore +from ...enumeration import ComponentEnum +from ...schema import FileEdge + + +_SYSTEM_PROMPT = """You are a high-precision information-extraction agent for a personal markdown vault. + +Your job for one file: identify subject-predicate-object triples that capture meaningful, lasting relations between named entities, then emit them by calling `emit_edges`. Quality > recall. + +You have read-only access to the vault via these tools: + - `memory_list(path_prefix, tags, limit)` — browse indexed files + - `memory_get(path)` — read frontmatter + body + edges of one file + - `memory_resolve_wikilink(wikilink)` — check whether `[[X]]` resolves to a real path + - `memory_links(path)` — outgoing edges from `path` + - `memory_backlinks(path)` — incoming edges to `path` + - `emit_edges(triples)` — finalize: list of {subject, predicate, object, confidence} + +Recommended loop (a few iterations is fine; do NOT exhaust the vault): + 1. Read the source file's text and metadata (provided in the user message). + 2. Generate candidate object entities from the text. + 3. Probe the vault: prefer `memory_resolve_wikilink` to confirm a candidate exists; fall back to `memory_list` for fuzzy browsing only when needed. + 4. Drop candidates that are pronouns / generic concepts / vague references / self-references. + 5. Call `emit_edges` ONCE with the final filtered set, then stop. + +Triple rules: + - subject: the source file's main entity (typically the file name / first H1). + - predicate: identifier-shaped, lowercase, words joined with underscore (e.g. `works_at`, `authored`, `depends_on`, `mentions`). + - object: the target entity name as it appears in the text — do NOT wrap with `[[ ]]`. + - confidence: float in [0, 1]; the parser drops anything below `min_confidence`. + +Skip silently: pronouns, self-referential relations, transient events, generic concepts. +If nothing meaningful is present, call `emit_edges([])` and stop.""" + + +_USER_TEMPLATE = """Source file: {path} + +Frontmatter: +{frontmatter} + +Body: +{text} + +Extract triples grounded in the vault now. Use the read tools as needed, then call `emit_edges` ONCE with the final list.""" + + +class _Triple(BaseModel): + """One subject-predicate-object triple from the LLM.""" + + subject: str = Field(description="Source entity of the relation.") + predicate: str = Field(description="Identifier-shaped relation name.") + object: str = Field(description="Target entity name.") + confidence: float = Field(ge=0.0, le=1.0, description="Self-rated confidence.") + + +_LINK_WRAPPER_RE = re.compile(r"^!?\[\[([^\]\|\#]+?)(?:[#\|][^\]]*)?\]\]$") +_PREDICATE_NORM_RE = re.compile(r"[^a-z0-9_]+") + + +def _strip_link_wrapper(s: str) -> str: + """Strip `[[X]]` / `![[X#h|alias]]` wrappers if the LLM emitted them.""" + s = s.strip().strip('"\'') + if m := _LINK_WRAPPER_RE.match(s): + return m.group(1).strip() + return s + + +def _normalize_predicate(pred: str) -> str: + """Lowercase + collapse non-identifier chars to `_`.""" + pred = pred.strip().lower() + pred = _PREDICATE_NORM_RE.sub("_", pred).strip("_") + return pred or "related" + + +def _text_response(payload: object) -> ToolResponse: + text = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + return ToolResponse(content=[TextBlock(type="text", text=text)]) + + +@R.register("llm") +class LLMEdgeExtractor(BaseEdgeExtractor): + """LLM-driven typed-edge extractor (slow route, ReAct over memory tools).""" + + def __init__( + self, + as_llm: str = "default", + as_llm_formatter: str = "default", + file_store: str = "default", + max_input_chars: int = 8000, + min_confidence: float = 0.5, + predicate_normalizer: bool = True, + max_iters: int = 8, + console_enabled: bool = False, + **kwargs, + ): + super().__init__(**kwargs) + self._as_llm_name: str = as_llm + self._as_llm_formatter_name: str = as_llm_formatter + self._file_store_name: str = file_store + self.max_input_chars: int = int(max_input_chars) + self.min_confidence: float = float(min_confidence) + self.predicate_normalizer: bool = bool(predicate_normalizer) + self.max_iters: int = int(max_iters) + self.console_enabled: bool = bool(console_enabled) + self.as_llm: ChatModelBase | None = None + self.as_llm_formatter: FormatterBase | None = None + self.file_store: BaseFileStore | None = None + + async def _start(self) -> None: + assert self.app_context is not None, "LLMEdgeExtractor requires app_context" + + llms = self.app_context.components.get(ComponentEnum.AS_LLM, {}) + wrapper = llms.get(self._as_llm_name) + if wrapper is None: + raise ValueError(f"as_llm '{self._as_llm_name}' not configured") + model = getattr(wrapper, "model", None) + if not isinstance(model, ChatModelBase): + raise TypeError( + f"as_llm '{self._as_llm_name}'.model is {type(model).__name__}, " + f"expected ChatModelBase", + ) + self.as_llm = model + + formatters = self.app_context.components.get(ComponentEnum.AS_LLM_FORMATTER, {}) + fwrapper = formatters.get(self._as_llm_formatter_name) + formatter = getattr(fwrapper, "formatter", None) if fwrapper is not None else None + if not isinstance(formatter, FormatterBase): + raise TypeError( + f"as_llm_formatter '{self._as_llm_formatter_name}' missing or wrong type", + ) + self.as_llm_formatter = formatter + + stores = self.app_context.components.get(ComponentEnum.FILE_STORE, {}) + store = stores.get(self._file_store_name) + if not isinstance(store, BaseFileStore): + raise ValueError( + f"file_store '{self._file_store_name}' not configured for LLMEdgeExtractor", + ) + self.file_store = store + + async def _close(self) -> None: + self.as_llm = None + self.as_llm_formatter = None + self.file_store = None + + async def extract( + self, + text: str, + metadata: dict | None = None, + path: str | None = None, + ) -> list[FileEdge]: + if not text or not text.strip(): + return [] + if self.as_llm is None or self.as_llm_formatter is None or self.file_store is None: + raise RuntimeError("LLMEdgeExtractor not started; call .start() first") + + body = text if len(text) <= self.max_input_chars else text[: self.max_input_chars] + + toolkit, sink = self._build_toolkit() + + agent = ReActAgent( + name="llm_edge_extractor", + model=self.as_llm, + sys_prompt=_SYSTEM_PROMPT, + formatter=self.as_llm_formatter, + toolkit=toolkit, + max_iters=self.max_iters, + ) + agent.set_console_output_enabled(self.console_enabled) + + user_msg = _USER_TEMPLATE.format( + path=path or "(unknown)", + frontmatter=json.dumps(metadata or {}, ensure_ascii=False, indent=2, default=str), + text=body, + ) + + try: + await agent.reply(Msg(name="user", role="user", content=user_msg)) + except Exception as e: + self.logger.warning(f"LLM IE agent failed for {path}: {e}") + return [] + + return self._sink_to_edges(sink) + + # -- Tool plumbing ------------------------------------------------------ + + def _build_toolkit(self) -> tuple[Toolkit, list[_Triple]]: + """Build a read-only memory toolkit + an `emit_edges` sink. + + Returns `(toolkit, sink)`. The sink is the mutable list the + `emit_edges` tool appends parsed triples into. + """ + assert self.file_store is not None + store = self.file_store + vault_root = store.vault_root or Path.cwd().resolve() + + sink: list[_Triple] = [] + + def _resolve(p: str) -> Path: + pp = Path(p) + if not pp.is_absolute(): + pp = vault_root / pp + return pp.resolve() + + async def memory_get(path: str) -> ToolResponse: + """Read one vault file: frontmatter + body + outgoing edges. + + Args: + path (str): Absolute or vault-relative path to the file. + """ + target = _resolve(path) + out: dict = {"path": str(target), "exists": False} + meta = store.get_file_meta(str(target)) + if meta is not None: + edges = store.get_edges(str(target)) + out.update({ + "exists": True, + "metadata": meta.metadata, + "edges": [e.model_dump(exclude_none=True) for e in edges], + }) + if target.is_file(): + try: + raw = target.read_text(encoding="utf-8") + post = frontmatter.loads(raw) + out["exists"] = True + out["metadata"] = dict(post.metadata) + out["content"] = post.content + except Exception as e: + out["read_error"] = str(e) + return _text_response(out) + + async def memory_list( + path_prefix: str = "", + tags: list[str] | None = None, + limit: int = 50, + ) -> ToolResponse: + """List indexed vault files, filtered by path prefix and tags. + + Args: + path_prefix (str): Restrict to paths starting with this prefix. + tags (list[str] | None): All tags must be present on a file. + limit (int): Cap on returned items. + """ + items: list[dict] = [] + for p, meta in store.nodes.items(): + if path_prefix and not p.startswith(path_prefix): + continue + md = meta.metadata or {} + if tags: + file_tags = set(md.get("tags", []) or []) + if not all(t in file_tags for t in tags): + continue + items.append({"path": p, "file": meta.file, "metadata": md}) + if len(items) >= limit: + break + return _text_response({"items": items, "count": len(items)}) + + async def memory_resolve_wikilink(wikilink: str) -> ToolResponse: + """Resolve a `[[wikilink]]` to its absolute path (or null if dangling). + + Args: + wikilink (str): The bare wikilink target (no brackets). + """ + hit = store.resolve_wikilink(wikilink) + return _text_response({"wikilink": wikilink, "path": hit}) + + async def memory_links(path: str) -> ToolResponse: + """Outgoing edges from `path` (resolved targets only). + + Args: + path (str): Absolute or vault-relative path. + """ + target = _resolve(path) + out = [ + {"path": m.path, "predicate": e.predicate, "metadata": m.metadata} + for m, e in store.get_links(str(target)) + ] + return _text_response({"path": str(target), "links": out}) + + async def memory_backlinks(path: str) -> ToolResponse: + """Incoming edges to `path` (files that link TO it). + + Args: + path (str): Absolute or vault-relative path. + """ + target = _resolve(path) + out = [ + {"path": m.path, "predicate": e.predicate, "metadata": m.metadata} + for m, e in store.get_backlinks(str(target)) + ] + return _text_response({"path": str(target), "backlinks": out}) + + async def emit_edges(triples: list[dict]) -> ToolResponse: + """Finalize extraction. Emit the full set of triples for this file. + + Call this exactly ONCE per file, with all triples you want to + commit. After this call, stop and produce a brief textual reply + summarizing what you emitted. + + Args: + triples (list[dict]): List of {subject, predicate, object, confidence}. + `confidence` ∈ [0, 1]; values below the configured threshold + are dropped by the parser. + """ + accepted = 0 + rejected = 0 + for t in triples: + try: + sink.append(_Triple.model_validate(t)) + accepted += 1 + except Exception: + rejected += 1 + return _text_response({ + "accepted": accepted, + "rejected": rejected, + "total_so_far": len(sink), + }) + + toolkit = Toolkit() + for fn in ( + memory_get, + memory_list, + memory_resolve_wikilink, + memory_links, + memory_backlinks, + emit_edges, + ): + toolkit.register_tool_function(fn, namesake_strategy="override") + return toolkit, sink + + def _sink_to_edges(self, sink: list[_Triple]) -> list[FileEdge]: + edges: list[FileEdge] = [] + for tri in sink: + if tri.confidence < self.min_confidence: + continue + target = _strip_link_wrapper(tri.object) + if not target or target == _strip_link_wrapper(tri.subject): + continue + predicate = ( + _normalize_predicate(tri.predicate) + if self.predicate_normalizer + else tri.predicate.strip() + ) + edges.append(FileEdge( + target=target, + predicate=predicate or None, + source="llm", + confidence=tri.confidence, + )) + return edges diff --git a/reme2/component/edge_extractor/regex_edge_extractor.py b/reme2/component/edge_extractor/regex_edge_extractor.py new file mode 100644 index 00000000..c20811b2 --- /dev/null +++ b/reme2/component/edge_extractor/regex_edge_extractor.py @@ -0,0 +1,47 @@ +"""RegexEdgeExtractor — fast route per `structure.md` §"双态抽取". + +Pulls edges from inline syntax + frontmatter walk in O(text length). +Synchronous and dependency-free; runs inside the watcher's hot path +without blocking on any external service. + +Sources of edges: + - body inline syntax: `[[X]]`, `![[X#Section|Alias]]`, + `[predicate:: [[X]]]`, `(predicate:: [[X]])` + - frontmatter walk: `author: "[[John]]"` → predicate=author, + `related: ["[[X]]", "[[Y]]"]` → two edges + +Dedup key: (target, predicate, anchor, alias, embed). When the same +edge appears in both body and frontmatter, the inline (regex) variant +wins — it's the more direct authoring intent. +""" + +from __future__ import annotations + +from .base_edge_extractor import BaseEdgeExtractor +from ..component_registry import R +from ...schema import FileEdge +from ...utils.wikilink import parse_wikilinks, parse_wikilinks_from_metadata + + +@R.register("regex") +class RegexEdgeExtractor(BaseEdgeExtractor): + """Inline-syntax + frontmatter typed-edge extractor.""" + + async def extract( + self, + text: str, + metadata: dict | None = None, + path: str | None = None, + ) -> list[FileEdge]: + body_edges = parse_wikilinks(text or "") + meta_edges = parse_wikilinks_from_metadata(metadata or {}) + + seen: set[tuple] = set() + out: list[FileEdge] = [] + for edge in (*body_edges, *meta_edges): + key = (edge.target, edge.predicate, edge.anchor, edge.alias, edge.embed) + if key in seen: + continue + seen.add(key) + out.append(edge) + return out diff --git a/reme2/component/embedding/base_embedding_model.py b/reme2/component/embedding/base_embedding_model.py index 21453bed..8ea7da96 100644 --- a/reme2/component/embedding/base_embedding_model.py +++ b/reme2/component/embedding/base_embedding_model.py @@ -26,17 +26,17 @@ class BaseEmbeddingModel(BaseComponent): 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 = 2000, - enable_cache: bool = True, - **kwargs, + 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 = 2000, + enable_cache: bool = True, + **kwargs, ): super().__init__(**kwargs) self.api_key: str = api_key or os.environ.get("EMBEDDING_API_KEY", "") @@ -81,7 +81,7 @@ class BaseEmbeddingModel(BaseComponent): self.logger.warning(f"Embedding dim {actual_len} < expected {self.dimensions}, padding") return embedding + [0.0] * (self.dimensions - actual_len) self.logger.warning(f"Embedding dim {actual_len} > expected {self.dimensions}, truncating") - return embedding[:self.dimensions] + return embedding[: self.dimensions] def _get_cache_key(self, text: str) -> str: """Generate cache key from text + model_name + dimensions.""" @@ -109,7 +109,9 @@ class BaseEmbeddingModel(BaseComponent): for key, emb in zip(data["keys"], data["embeddings"]): emb_list = emb.tolist() if len(emb_list) != self.dimensions: - self.logger.warning(f"Cache dimension mismatch for {key}: expected {self.dimensions}, got {len(emb_list)}") + self.logger.warning( + f"Cache dimension mismatch for {key}: expected {self.dimensions}, got {len(emb_list)}", + ) continue if len(self._embedding_cache) >= self.max_cache_size: self.logger.info(f"Cache limit reached ({self.max_cache_size}), loaded {loaded_count}") @@ -188,7 +190,7 @@ class BaseEmbeddingModel(BaseComponent): async def get_embeddings(self, input_text: list[str], **kwargs) -> list[list[float] | None]: """Get embeddings for multiple texts with cache and batching.""" # TODO change to bytes instead of str - truncated_texts = [t[:self.max_input_length] for t in input_text] + truncated_texts = [t[: self.max_input_length] for t in input_text] results: list[list[float] | None] = [None] * len(truncated_texts) texts_to_compute: list[tuple[int, str]] = [] @@ -202,7 +204,7 @@ class BaseEmbeddingModel(BaseComponent): if texts_to_compute: uncached_texts = [text for _, text in texts_to_compute] for i in range(0, len(uncached_texts), self.max_batch_size): - batch = texts_to_compute[i:i + self.max_batch_size] + batch = texts_to_compute[i : i + self.max_batch_size] batch_indices = [idx for idx, _ in batch] batch_texts = [text for _, text in batch] diff --git a/reme2/component/file_parser/__init__.py b/reme2/component/file_parser/__init__.py index de6491c7..17239557 100644 --- a/reme2/component/file_parser/__init__.py +++ b/reme2/component/file_parser/__init__.py @@ -1,11 +1,15 @@ """File parser implementations for different file formats.""" from .base_file_parser import BaseFileParser -from .default_file_parser import DefaultFileParser from .md_file_parser import MdFileParser +from .text_file_parser import TextFileParser + +# Backwards-compat alias. +DefaultFileParser = TextFileParser __all__ = [ "BaseFileParser", "DefaultFileParser", "MdFileParser", + "TextFileParser", ] diff --git a/reme2/component/file_parser/base_file_parser.py b/reme2/component/file_parser/base_file_parser.py index 580ee445..a79706fe 100644 --- a/reme2/component/file_parser/base_file_parser.py +++ b/reme2/component/file_parser/base_file_parser.py @@ -1,18 +1,98 @@ -"""Abstract base class for file parsers.""" +"""Abstract base class for file parsers. + +Single-pass parser: `parse(path, existing_chunks)` returns a `ParsedFile` +carrying metadata + chunks (with embeddings attached) + edges. + +The parser owns the embedding pipeline. To avoid re-embedding unchanged +blocks, the watcher passes in the file's prior chunks; the parser +hash-diffs and only calls the embedding API for blocks whose hash is +new. Vanished hashes simply drop out (the file_store's upsert is a +delete-and-insert). + +If `existing_chunks` is None / empty, every chunk is embedded fresh. +""" from abc import abstractmethod from ..base_component import BaseComponent +from ..embedding import BaseEmbeddingModel from ...enumeration import ComponentEnum, FileSuffixEnum -from ...schema import FileChunk, FileMetadata +from ...schema import FileChunk, ParsedFile class BaseFileParser(BaseComponent): - """Parser that declares handled suffixes and produces FileChunks.""" + """Single-pass parser producing an embedded `ParsedFile`.""" component_type = ComponentEnum.FILE_PARSER suffixes: list[FileSuffixEnum] = [] + def __init__(self, embedding_model: str = "", **kwargs): + super().__init__(**kwargs) + self._embedding_model_name: str = embedding_model + self.embedding_model: BaseEmbeddingModel | None = None + + async def _start(self) -> None: + if not self._embedding_model_name: + return + assert self.app_context is not None, "app_context must be provided" + models = self.app_context.components.get(ComponentEnum.EMBEDDING_MODEL, {}) + if self._embedding_model_name not in models: + raise ValueError(f"Embedding model '{self._embedding_model_name}' not found.") + model = models[self._embedding_model_name] + if not isinstance(model, BaseEmbeddingModel): + raise TypeError(f"Expected BaseEmbeddingModel, got {type(model).__name__}") + self.embedding_model = model + + async def _close(self) -> None: + self.embedding_model = None + + async def _embed_chunks(self, chunks: list[FileChunk]) -> list[FileChunk]: + """Attach embeddings to chunks if an embedding model is configured. + + Failures (missing embeddings) leave `chunk.embedding=None`; the + file_store can still keyword-search them. + """ + if not chunks or self.embedding_model is None: + return chunks + try: + await self.embedding_model.get_node_embeddings(chunks) + except Exception as e: + self.logger.warning(f"embedding chunks failed: {e}") + return chunks + + @staticmethod + def _hash_diff_attach( + chunks: list[FileChunk], + existing_chunks: list[FileChunk] | None, + ) -> list[FileChunk]: + """Attach cached embeddings to chunks whose hash already exists. + + Returns the dirty subset (chunks still needing embeddings). Mutates + the input chunks in place: matched chunks get their `embedding` + field set from the cache. + """ + if not existing_chunks: + return list(chunks) + cached = {c.hash: c.embedding for c in existing_chunks if c.embedding} + if not cached: + return list(chunks) + dirty: list[FileChunk] = [] + for c in chunks: + cached_emb = cached.get(c.hash) + if cached_emb is not None: + c.embedding = cached_emb + else: + dirty.append(c) + return dirty + @abstractmethod - async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]: - """Parse a file into metadata and chunks.""" + async def parse( + self, + path: str, + existing_chunks: list[FileChunk] | None = None, + ) -> ParsedFile: + """Parse + embed the file, returning a fully populated `ParsedFile`. + + `existing_chunks`, when provided, lets the parser reuse cached + embeddings for blocks whose hash hasn't changed. + """ diff --git a/reme2/component/file_parser/md_file_parser.py b/reme2/component/file_parser/md_file_parser.py index fdb60373..7300e7b1 100644 --- a/reme2/component/file_parser/md_file_parser.py +++ b/reme2/component/file_parser/md_file_parser.py @@ -1,54 +1,226 @@ -"""Markdown file parser.""" +"""Markdown file parser — frontmatter + wikilink graph + AST-aware chunking. -import asyncio +The chunker splits markdown into semantic blocks using: + - ATX headings (#, ##, ..., ######) as section anchors + - Blank lines (paragraph boundaries) as soft splits within a section + - Code fences (``` or ~~~) preserved as a single block + +Each block carries a `heading_path` breadcrumb prepended to its text — gives +the embedding model section context AND lets retrieval results show callers +where the hit lives. The hash is computed over the final text (with +breadcrumb), so renaming a heading correctly invalidates child block +embeddings. + +Hash-diff cache compatibility: blocks with identical (heading_path + body) +across edits produce the same hash, so the watcher can reuse old embeddings +and only call the embedding API for dirty blocks. + +Edge extraction is delegated to a `BaseEdgeExtractor` resolved from the +app context (`edge_extractor` constructor arg, defaults to "default"). If +the app context isn't bound or the named extractor isn't registered, the +parser falls back to a local `RegexEdgeExtractor` so unit tests / simple +scripts can still parse without YAML wiring. +""" + +import re from pathlib import Path import frontmatter from .base_file_parser import BaseFileParser from ..component_registry import R -from ...enumeration import FileSuffixEnum -from ...schema import FileChunk, FileMetadata -from ...utils import chunk_markdown +from ..edge_extractor import BaseEdgeExtractor, RegexEdgeExtractor +from ...enumeration import ComponentEnum, FileSuffixEnum +from ...schema import FileChunk, ParsedFile +from ...utils import hash_text @R.register("md") class MdFileParser(BaseFileParser): - """Parser for Markdown files with YAML frontmatter support.""" + """Parser for Markdown files with YAML frontmatter and wikilink support.""" suffixes = [FileSuffixEnum.MD, FileSuffixEnum.MARKDOWN] - def __init__(self, encoding: str = "utf-8", chunk_tokens: int = 400, chunk_overlap: int = 80, **kwargs): + _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") + _FENCE_RE = re.compile(r"^(```|~~~)") + + def __init__( + self, + encoding: str = "utf-8", + edge_extractor: str = "default", + **kwargs, + ): super().__init__(**kwargs) self.encoding = encoding - self.chunk_tokens = chunk_tokens - self.chunk_overlap = chunk_overlap + self._edge_extractor_name: str = edge_extractor + self.edge_extractor: BaseEdgeExtractor | None = None - async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]: + async def _start(self) -> None: + await super()._start() + # Try to resolve the configured extractor; fall back to a local + # regex extractor when no app_context / no matching component. + resolved: BaseEdgeExtractor | None = None + if self.app_context is not None and self._edge_extractor_name: + extractors = self.app_context.components.get(ComponentEnum.EDGE_EXTRACTOR, {}) + candidate = extractors.get(self._edge_extractor_name) + if isinstance(candidate, BaseEdgeExtractor): + resolved = candidate + if resolved is None: + resolved = RegexEdgeExtractor() + await resolved.start() + self.edge_extractor = resolved + + async def _close(self) -> None: + await super()._close() + self.edge_extractor = None + + async def parse( + self, + path: str, + existing_chunks: list[FileChunk] | None = None, + ) -> ParsedFile: file_path = Path(path) + raw = file_path.read_text(encoding=self.encoding) + post = frontmatter.loads(raw) + stat = file_path.stat() + metadata = dict(post.metadata) + content = post.content + absolute_path = str(file_path.absolute()) - def _read_and_parse(): - raw = file_path.read_text(encoding=self.encoding) - post = frontmatter.loads(raw) - stat = file_path.stat() - return stat, dict(post.metadata), post.content + extractor = self.edge_extractor or RegexEdgeExtractor() + edges = await extractor.extract(content, metadata, path=absolute_path) - stat, metadata, content = await asyncio.to_thread(_read_and_parse) + chunks = self.chunk_markdown(content, absolute_path) + dirty = self._hash_diff_attach(chunks, existing_chunks) + if dirty: + await self._embed_chunks(dirty) - file_meta = FileMetadata( - modified_time=stat.st_mtime, - path=str(file_path.absolute()), + return ParsedFile( + file=file_path.stem, + path=absolute_path, + st_mtime=stat.st_mtime, metadata=metadata, + edges=edges, + chunks=chunks, ) - chunks = ( - chunk_markdown( - content, - file_meta.path, - self.chunk_tokens, - self.chunk_overlap, - ) - or [] - ) + # -- Chunker ---------------------------------------------------------- - return file_meta, chunks + @staticmethod + def _breadcrumb(heading_path: list[str]) -> str: + return " > ".join(heading_path) if heading_path else "" + + @classmethod + def _make_block_text(cls, heading_path: list[str], body: str) -> str: + """Compose final block text: breadcrumb line (if any) + blank + body.""" + body = body.rstrip("\n") + crumb = cls._breadcrumb(heading_path) + if crumb: + return f"{crumb}\n\n{body}" if body else crumb + return body + + @classmethod + def chunk_markdown(cls, text: str, path: str) -> list[FileChunk]: + """Split markdown into AST-aware blocks (headings / paragraphs / fences).""" + if not text or not text.strip(): + return [] + + lines = text.split("\n") + chunks: list[FileChunk] = [] + + heading_stack: list[tuple[int, str]] = [] # [(level, title)] + body_lines: list[str] = [] + body_start = 1 + in_fence = False + fence_marker = "" + + def current_path() -> list[str]: + return [t for _, t in heading_stack] + + def emit(block_text: str, start_line: int, end_line: int) -> None: + h = hash_text(block_text) + chunks.append( + FileChunk( + id=hash_text(f"{path}::{start_line}::{end_line}::{h}::{len(chunks)}"), + path=path, + start_line=start_line, + end_line=end_line, + text=block_text, + hash=h, + ), + ) + + def flush_body(end_line: int) -> None: + nonlocal body_lines, body_start + if not body_lines: + return + # Strip leading/trailing blank lines from the block (paragraph + # boundaries eat their own newline, but whitespace can sneak in + # via the fence path). + while body_lines and not body_lines[0].strip(): + body_lines.pop(0) + body_start += 1 + while body_lines and not body_lines[-1].strip(): + body_lines.pop() + end_line -= 1 + if not body_lines: + body_lines = [] + return + raw_body = "\n".join(body_lines) + block_text = cls._make_block_text(current_path(), raw_body) + emit(block_text, body_start, end_line) + body_lines = [] + + for i, line in enumerate(lines, 1): + stripped = line.strip() + + # Code fences: keep contents intact, no inner splits. + if not in_fence and cls._FENCE_RE.match(stripped): + if body_lines: + flush_body(end_line=i - 1) + in_fence = True + fence_marker = stripped[:3] + body_lines = [line] + body_start = i + continue + if in_fence: + body_lines.append(line) + if stripped.startswith(fence_marker): + in_fence = False + fence_marker = "" + flush_body(end_line=i) + continue + + # ATX heading: closes prior block, opens a new section. + m = cls._HEADING_RE.match(line) + if m: + if body_lines: + flush_body(end_line=i - 1) + level = len(m.group(1)) + title = m.group(2).strip() + heading_stack = [(lv, t) for lv, t in heading_stack if lv < level] + heading_stack.append((level, title)) + + # Heading line itself becomes a block (so the heading text is + # searchable as its own unit). + block_text = cls._make_block_text(current_path(), "") + emit(block_text, i, i) + body_start = i + 1 + continue + + # Blank line: paragraph boundary. + if not stripped: + if body_lines: + flush_body(end_line=i - 1) + body_start = i + 1 + continue + + # Regular content line. + if not body_lines: + body_start = i + body_lines.append(line) + + if body_lines: + flush_body(end_line=len(lines)) + + return chunks diff --git a/reme2/component/file_parser/text_file_parser.py b/reme2/component/file_parser/text_file_parser.py index 490714e9..193cb38a 100644 --- a/reme2/component/file_parser/text_file_parser.py +++ b/reme2/component/file_parser/text_file_parser.py @@ -8,7 +8,7 @@ import aiofiles from .base_file_parser import BaseFileParser from ..component_registry import R from ...enumeration import FileSuffixEnum -from ...schema import FileChunk, FileMetadata +from ...schema import FileChunk, ParsedFile @R.register("text") @@ -23,9 +23,14 @@ class TextFileParser(BaseFileParser): self.chunk_size = max(32, chunk_tokens * 4) self.overlap_size = max(0, chunk_overlap * 4) - async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]: + async def parse( + self, + path: str, + existing_chunks: list[FileChunk] | None = None, + ) -> ParsedFile: file_path = Path(path) stat = file_path.stat() + absolute_path = str(file_path.absolute()) try: async with aiofiles.open(file_path, encoding=self.encoding) as f: @@ -34,17 +39,20 @@ class TextFileParser(BaseFileParser): async with aiofiles.open(file_path, encoding=self.encoding, errors="ignore") as f: content = await f.read() except Exception: - content = None + content = "" - file_meta = FileMetadata( + chunks = self._chunk(content, absolute_path) if content else [] + dirty = self._hash_diff_attach(chunks, existing_chunks) + if dirty: + await self._embed_chunks(dirty) + + return ParsedFile( file=file_path.stem, - path=str(file_path.absolute()), + path=absolute_path, st_mtime=stat.st_mtime, + chunks=chunks, ) - chunks = self._chunk(content, file_meta.path) if content else [] - return file_meta, chunks - def _chunk(self, text: str, path: str) -> list[FileChunk]: """Split text into chunks with overlap.""" if not text.strip(): @@ -58,7 +66,7 @@ class TextFileParser(BaseFileParser): for line_no, line in enumerate(lines, 1): # Split long lines into segments for start in range(0, max(1, len(line)), self.chunk_size): - seg = line[start:start + self.chunk_size] + seg = line[start : start + self.chunk_size] seg_chars = len(seg) + 1 # +1 for newline # Flush when buffer would exceed limit @@ -82,14 +90,16 @@ class TextFileParser(BaseFileParser): h = hashlib.sha256(chunk_text.encode()).hexdigest() chunk_id = hashlib.sha256(f"{path}:{start_line}:{end_line}:{h}:{len(chunks)}".encode()).hexdigest() - chunks.append(FileChunk( - id=chunk_id, - path=path, - start_line=start_line, - end_line=end_line, - text=chunk_text, - hash=h, - )) + chunks.append( + FileChunk( + id=chunk_id, + path=path, + start_line=start_line, + end_line=end_line, + text=chunk_text, + hash=h, + ), + ) def _carry_overlap(self, buf: list[tuple[str, int]]) -> tuple[list[tuple[str, int]], int]: """Keep overlapping lines from the end of buffer.""" diff --git a/reme2/component/file_store/__init__.py b/reme2/component/file_store/__init__.py new file mode 100644 index 00000000..64d3c3b9 --- /dev/null +++ b/reme2/component/file_store/__init__.py @@ -0,0 +1,26 @@ +"""File store module. + +Unified storage for file metadata (frontmatter + mtime), the wikilink +graph (edges per path), and chunks (text + embeddings) with vector / +keyword / hybrid search. + +The store accepts a `ParsedFile` from the watcher's parsing pass via +`upsert_parsed(...)`, dispatching meta + edges + chunks into the +appropriate persistence pipelines. + +Two backends: + LocalFileStore — pure-Python with JSONL persistence (default, + zero deps, fine for small vaults). + SqliteFileStore — SQLite + FTS5 + sqlite-vec for keyword/vector + at scale; meta + edges live in relational tables. +""" + +from .base_file_store import BaseFileStore +from .local_file_store import LocalFileStore +from .sqlite_file_store import SqliteFileStore + +__all__ = [ + "BaseFileStore", + "LocalFileStore", + "SqliteFileStore", +] diff --git a/reme2/component/file_store/base_file_store.py b/reme2/component/file_store/base_file_store.py new file mode 100644 index 00000000..c1493542 --- /dev/null +++ b/reme2/component/file_store/base_file_store.py @@ -0,0 +1,629 @@ +"""Abstract base class for file stores. + +A `FileStore` manages **everything** about a file in the vault: + - file metadata (frontmatter + mtime) + - the wikilink graph: edges per file (links / backlinks / resolution) + - graph-walk retrieval primitives (BFS expansion, decay scoring) + - chunks (text + embeddings + position) with vector / keyword / hybrid search + +Edges are stored independently of metadata in `_edges: dict[path, +list[FileEdge]]`. The watcher submits a `ParsedFile` via +`upsert_parsed(parsed)`; the store fans this out to meta + edge + chunk +persistence in a single atomic-from-the-caller's-POV operation. + +Reads (`get_file_meta`, `get_links`, `resolve_wikilink`, `subgraph_score`, +`filter`, ...) are **synchronous** — they hit an in-memory index that is +rebuilt from the persistent backend on `_start`. Writes are **async** — +they touch persistence first, then update the in-memory index, so on +crash the on-disk view is the source of truth. + +Backends supply persistence for chunks, file metadata, AND edges via +abstract methods. The default meta + edge persistence is sidecar JSONL; +SQLite overrides with relational tables. +""" + +import re +from abc import abstractmethod +from collections import defaultdict, deque +from pathlib import Path +from typing import Iterable + +from ..base_component import BaseComponent +from ..embedding import BaseEmbeddingModel +from ...enumeration import ComponentEnum +from ...schema import ChunkFilter, FileChunk, FileEdge, FileMetadata, ParsedFile +from ...utils.wikilink import extract_wikilinks + + +def _target_stem(raw: str) -> str: + """Stem of a raw wikilink target — `"topics/X/X"` → `"X"`.""" + target = raw.strip() + if target.endswith(".md"): + target = target[:-3] + return target.rsplit("/", 1)[-1] + + +class BaseFileStore(BaseComponent): + """Unified file store: metadata + edges + chunks + graph + search.""" + + component_type = ComponentEnum.FILE_STORE + + def __init__( + self, + store_name: str, + db_path: str | Path, + embedding_model: str = "default", + fts_enabled: bool = True, + **kwargs, + ): + super().__init__(**kwargs) + self._embedding_model_name: str = embedding_model + self.embedding_model: BaseEmbeddingModel | None = None + self.store_name: str = store_name + self.db_path: Path = Path(db_path) + self.db_path.mkdir(parents=True, exist_ok=True) + self.vector_enabled: bool = bool(embedding_model) + self.fts_enabled: bool = fts_enabled + + if not re.match(r"^[a-zA-Z0-9_]+$", store_name): + raise ValueError( + f"Invalid store name '{store_name}'. Only alphanumeric characters and underscores are allowed.", + ) + if not self.vector_enabled and not self.fts_enabled: + raise ValueError("At least one of embedding_model or fts_enabled must be set.") + + # In-memory indices (rebuilt on _start from persisted state). + self._nodes: dict[str, FileMetadata] = {} + self._edges: dict[str, list[FileEdge]] = {} + self._stems: dict[str, set[str]] = defaultdict(set) + self._backlinks: dict[str, set[str]] = defaultdict(set) + self.vault_root: Path | None = None + + # -- Lifecycle ---------------------------------------------------------- + + async def _start(self): + if self._embedding_model_name: + assert self.app_context is not None, "app_context must be provided" + models = self.app_context.components.get(ComponentEnum.EMBEDDING_MODEL, {}) + if self._embedding_model_name not in models: + raise ValueError(f"Embedding model '{self._embedding_model_name}' not found.") + model = models[self._embedding_model_name] + if not isinstance(model, BaseEmbeddingModel): + raise TypeError(f"Expected BaseEmbeddingModel, got {type(model).__name__}") + self.embedding_model = model + # Concrete backend's _start runs first (opens conn, creates tables); + # this base _start runs LAST so persistence is ready by the time we + # iterate it. Subclasses should `await super()._start()` at the END + # of their own _start. + await self._reload_file_metas() + + async def _close(self): + self.embedding_model = None + self._nodes.clear() + self._edges.clear() + self._stems.clear() + self._backlinks.clear() + + async def _reload_file_metas(self) -> None: + """Rebuild in-memory indices from persisted meta + edges. Called on _start.""" + self._nodes.clear() + self._edges.clear() + self._stems.clear() + self._backlinks.clear() + count = 0 + for meta in self._iter_persisted_metas(): + self._nodes[meta.path] = meta + self._stems[Path(meta.path).stem].add(meta.path) + count += 1 + edge_count = 0 + for path, edges in self._iter_persisted_edges(): + if path not in self._nodes: + continue # orphan — meta was already pruned + self._edges[path] = edges + for edge in edges: + self._backlinks[_target_stem(edge.target)].add(path) + edge_count += 1 + self.logger.info( + f"[{self.store_name}] Loaded {count} file metas, {edge_count} edges into in-memory index", + ) + + # -- Embedding helpers -------------------------------------------------- + + @property + def embedding_dim(self) -> int: + return self.embedding_model.dimensions if self.embedding_model else 1024 + + def _disable_vector_search(self, reason: str = "embedding API error") -> None: + if self.vector_enabled: + self.logger.warning(f"[{self.store_name}] Disabling vector search: {reason}") + self.vector_enabled = False + + async def _get_embeddings_safe(self, texts: list[str], **kwargs) -> list[list[float]] | None: + if not self.vector_enabled: + return None + try: + assert self.embedding_model is not None + return await self.embedding_model.get_embeddings(texts, **kwargs) + except Exception as e: + self._disable_vector_search(str(e)) + return None + + async def get_embedding(self, query: str, **kwargs) -> list[float] | None: + result = await self._get_embeddings_safe([query], **kwargs) + return result[0] if result else None + + async def get_embeddings(self, queries: list[str], **kwargs) -> list[list[float]] | None: + return await self._get_embeddings_safe(queries, **kwargs) + + # -- Vault root --------------------------------------------------------- + + def set_vault_root(self, vault_root: str | Path | None) -> None: + """Bind the runtime vault root for explicit-path wikilink resolution.""" + self.vault_root = Path(vault_root).resolve() if vault_root is not None else None + + # -- File-meta CRUD (async writes; sync reads on in-memory index) ------- + + async def upsert_file_meta(self, meta: FileMetadata) -> None: + """Persist + index a file meta. Replaces any prior entry for the path. + + Edges are NOT touched here — call `upsert_edges` separately, or + use `upsert_parsed` for the combined fan-out from a `ParsedFile`. + """ + await self._persist_upsert_meta(meta) + self._nodes[meta.path] = meta + self._stems[Path(meta.path).stem].add(meta.path) + + async def update_file_meta(self, path: str, **fields) -> FileMetadata | None: + """Patch an existing meta (model_copy + upsert). No-op if path unknown.""" + existing = self._nodes.get(path) + if existing is None: + return None + updated = existing.model_copy(update=fields) + await self.upsert_file_meta(updated) + return updated + + async def delete_file_meta(self, path: str) -> FileMetadata | None: + """Persist deletion + drop from index. Also drops the file's edges. + + Returns prior meta if any. + """ + await self._persist_delete_meta(path) + await self._persist_delete_edges(path) + existing = self._nodes.pop(path, None) + if existing is not None: + stem = Path(existing.path).stem + self._stems.get(stem, set()).discard(existing.path) + if not self._stems.get(stem): + self._stems.pop(stem, None) + for edge in self._edges.pop(path, []): + tgt = _target_stem(edge.target) + self._backlinks.get(tgt, set()).discard(path) + if not self._backlinks.get(tgt): + self._backlinks.pop(tgt, None) + return existing + + def get_file_meta(self, path: str) -> FileMetadata | None: + return self._nodes.get(path) + + @property + def nodes(self) -> dict[str, FileMetadata]: + return self._nodes + + def __len__(self) -> int: + return len(self._nodes) + + def __contains__(self, path: str) -> bool: + return path in self._nodes + + # -- Edge CRUD ---------------------------------------------------------- + + async def upsert_edges(self, path: str, edges: list[FileEdge]) -> None: + """Persist + reindex the edge list for `path`. Replaces any prior set.""" + await self._persist_upsert_edges(path, edges) + # Drop old backlink contributions from this source. + for edge in self._edges.get(path, []): + tgt = _target_stem(edge.target) + self._backlinks.get(tgt, set()).discard(path) + if not self._backlinks.get(tgt): + self._backlinks.pop(tgt, None) + if edges: + self._edges[path] = list(edges) + for edge in edges: + self._backlinks[_target_stem(edge.target)].add(path) + else: + self._edges.pop(path, None) + + def get_edges(self, path: str) -> list[FileEdge]: + """All edges originating from `path` (raw — not resolved).""" + return list(self._edges.get(path, ())) + + async def upsert_parsed(self, parsed: ParsedFile) -> None: + """Single fan-out entry point for the watcher's parse pass. + + Persists meta, edges, then chunks — the watcher gets one call and + the store handles the three sub-payloads in order. + """ + await self.upsert_file_meta(FileMetadata( + file=parsed.file, + path=parsed.path, + st_mtime=parsed.st_mtime, + metadata=parsed.metadata, + )) + await self.upsert_edges(parsed.path, parsed.edges) + await self.upsert_chunks(parsed.path, parsed.chunks) + + # -- Link queries (sync, in-memory) ------------------------------------- + + def get_links(self, path: str) -> list[tuple[FileMetadata, FileEdge]]: + """Files this `path` links TO (resolved only). + + Returns a list of `(target_meta, edge)` pairs — predicate / anchor / + source / confidence are preserved on the edge so callers can reason + about typed graph structure. The same target file can appear multiple + times if linked via several edges (e.g. plain `[[X]]` plus + `[author:: [[X]]]`); caller dedupes by `target_meta.path` if needed. + """ + if path not in self._nodes: + return [] + out: list[tuple[FileMetadata, FileEdge]] = [] + for edge in self._edges.get(path, ()): + hit = self.resolve_wikilink(edge.target) + if hit is not None and hit in self._nodes: + out.append((self._nodes[hit], edge)) + return out + + def get_backlinks(self, path: str) -> list[tuple[FileMetadata, FileEdge]]: + """Files that link TO `path` (resolved only). + + Returns a list of `(source_meta, edge)` pairs — `edge` is the + specific FileEdge on the source file that resolves to `path`. A + single source can appear multiple times if it points at `path` via + multiple edges. + """ + if path not in self._nodes: + return [] + stem = Path(path).stem + candidates = self._backlinks.get(stem, set()) + out: list[tuple[FileMetadata, FileEdge]] = [] + for src in candidates: + src_meta = self._nodes.get(src) + if src_meta is None: + continue + for edge in self._edges.get(src, ()): + if self.resolve_wikilink(edge.target) == path: + out.append((src_meta, edge)) + return out + + def get_paths_by_stem(self, stem: str) -> list[str]: + return sorted(self._stems.get(stem, set())) + + # -- Wikilink resolution ------------------------------------------------ + + def resolve_wikilink(self, wikilink: str) -> str | None: + target = wikilink.strip() + if not target: + return None + + if "/" in target or target.endswith(".md"): + if self.vault_root is None: + return None + candidate = target if target.endswith(".md") else f"{target}.md" + abs_candidate = str((self.vault_root / candidate).resolve()) + if abs_candidate in self._nodes: + return abs_candidate + return None + + candidates = self.wikilink_candidates(target) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + self.logger.warning( + f"Wikilink [[{target}]] is ambiguous, candidates: {candidates}", + ) + return None + + def resolve_wikilinks(self, wikilinks: list[str]) -> tuple[list[str], list[str]]: + """Return (resolved_paths, dangling_targets).""" + resolved: list[str] = [] + dangling: list[str] = [] + for link in wikilinks: + hit = self.resolve_wikilink(link) + if hit is None: + dangling.append(link) + else: + resolved.append(hit) + return resolved, dangling + + def wikilink_candidates(self, target: str) -> list[str]: + """Paths `[[target]]` would resolve to (folder-note hit wins).""" + folder_hits = self._folder_notes(target) + if folder_hits: + return folder_hits + return self.get_paths_by_stem(target) + + def collisions_after_create(self, proposed_path: str | Path) -> list[str]: + """Existing paths that would conflict with adding `proposed_path`.""" + p = Path(proposed_path) + stem = p.stem + proposed_abs = str(p.resolve()) + is_folder_note = p.parent.name == stem + + existing_folder_notes = [path for path in self._folder_notes(stem) if path != proposed_abs] + existing_stems = [path for path in self.get_paths_by_stem(stem) if path != proposed_abs] + + if is_folder_note: + return existing_folder_notes + return existing_folder_notes + [sp for sp in existing_stems if sp not in existing_folder_notes] + + def all_ambiguous_wikilinks(self) -> dict[str, list[str]]: + """Stems whose `[[stem]]` form has >1 candidate.""" + out: dict[str, list[str]] = {} + for stem in self._stems: + cands = self.wikilink_candidates(stem) + if len(cands) > 1: + out[stem] = cands + return out + + def _folder_notes(self, stem: str) -> list[str]: + return sorted(p for p in self._stems.get(stem, ()) if Path(p).parent.name == stem) + + # -- Graph-walk retrieval primitives ------------------------------------ + + def expand_neighbors( + self, + seed_paths: Iterable[str], + depth: int = 1, + direction: str = "both", + per_node_cap: int | None = 50, + ) -> dict[str, int]: + """BFS expansion over wikilink edges. Returns `{path: hop_distance}`.""" + if direction not in ("out", "in", "both"): + raise ValueError(f"direction must be one of out/in/both, got {direction!r}") + if depth < 0: + raise ValueError(f"depth must be >= 0, got {depth}") + + seen: dict[str, int] = {} + frontier: deque[tuple[str, int]] = deque() + for path in seed_paths: + if path in self._nodes and path not in seen: + seen[path] = 0 + frontier.append((path, 0)) + + while frontier: + path, dist = frontier.popleft() + if dist >= depth: + continue + + neighbors: list[str] = [] + if direction in ("out", "both"): + neighbors.extend(m.path for m, _ in self.get_links(path)) + if direction in ("in", "both"): + neighbors.extend(m.path for m, _ in self.get_backlinks(path)) + + if per_node_cap is not None and len(neighbors) > per_node_cap: + neighbors = neighbors[:per_node_cap] + + for nb in neighbors: + if nb in seen: + continue + seen[nb] = dist + 1 + frontier.append((nb, dist + 1)) + + return seen + + def subgraph_score( + self, + seed_paths: Iterable[str], + decay: float = 0.5, + depth: int = 1, + direction: str = "both", + per_node_cap: int | None = 50, + ) -> dict[str, float]: + """Decayed score per path: seed=1.0, 1-hop=decay, 2-hop=decay²...""" + if not (0.0 <= decay <= 1.0): + raise ValueError(f"decay must be in [0, 1], got {decay}") + hops = self.expand_neighbors(seed_paths, depth, direction, per_node_cap) + return {path: decay ** hop for path, hop in hops.items()} + + def extract_anchor_paths(self, text: str) -> list[str]: + """Pull `[[X]]` anchors from `text` and resolve each (deduped).""" + seen: set[str] = set() + out: list[str] = [] + for raw in extract_wikilinks(text): + hit = self.resolve_wikilink(raw) + if hit is not None and hit not in seen: + seen.add(hit) + out.append(hit) + return out + + # -- Filter compilation ------------------------------------------------- + + def filter( + self, + paths: list[str] | None = None, + tags: list[str] | None = None, + exclude_paths: list[str] | None = None, + ) -> ChunkFilter: + """Compile user-facing intent into a path-set filter for chunk search.""" + cf = ChunkFilter(paths=paths, tags=tags, exclude_paths=exclude_paths) + if cf.is_empty(): + return cf + cf.resolved_paths = {p for p, m in self._nodes.items() if cf.match_metadata(p, m.metadata)} + return cf + + # -- Keyword scoring utility -------------------------------------------- + + @staticmethod + def _score_keyword_match(query: str, text: str) -> float: + words = query.split() + if not words: + return 0.0 + query_lower = query.lower() + words_lower = [w.lower() for w in words] + text_lower = text.lower() + n_words = len(words) + + match_count = sum(1 for w in words_lower if w in text_lower) + if match_count == 0: + return 0.0 + + base_score = match_count / n_words + phrase_bonus = 0.2 if n_words > 1 and query_lower in text_lower else 0.0 + return min(1.0, base_score + phrase_bonus) + + # -- Filter utility (chunk side) ---------------------------------------- + + @staticmethod + def _apply_filter(chunks: list[FileChunk], chunk_filter: ChunkFilter | None) -> list[FileChunk]: + if chunk_filter is None or chunk_filter.resolved_paths is None: + return chunks + return [c for c in chunks if chunk_filter.match_path(c.path)] + + # -- Default file-meta persistence (sidecar JSONL) ---------------------- + + @property + def _file_metas_path(self) -> Path: + return self.db_path / f"{self.store_name}_files.jsonl" + + async def _persist_upsert_meta(self, meta: FileMetadata) -> None: + """Default: full rewrite of the sidecar jsonl. Backends with proper + relational storage (sqlite) override this for per-row UPSERT.""" + # Snapshot current in-memory state with the new entry merged in. + # Note: in-memory not yet updated by caller — we add `meta` ourselves. + snapshot = {**self._nodes, meta.path: meta} + self._write_metas_jsonl(snapshot.values()) + + async def _persist_delete_meta(self, path: str) -> None: + """Default: full rewrite of the sidecar jsonl, omitting `path`.""" + snapshot = {p: m for p, m in self._nodes.items() if p != path} + self._write_metas_jsonl(snapshot.values()) + + def _iter_persisted_metas(self) -> Iterable[FileMetadata]: + """Default: read all rows from the sidecar jsonl.""" + path = self._file_metas_path + if not path.exists(): + return [] + out: list[FileMetadata] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + out.append(FileMetadata.model_validate_json(line)) + except Exception as e: + self.logger.warning(f"Bad row in {path}: {e}") + return out + + def _write_metas_jsonl(self, metas: Iterable[FileMetadata]) -> None: + path = self._file_metas_path + lines = [m.model_dump_json() for m in metas] + content = "\n".join(lines) + tmp = path.with_suffix(".tmp") + try: + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + except Exception as e: + self.logger.error(f"Failed to write {path}: {e}") + raise + finally: + if tmp.exists(): + tmp.unlink() + + # -- Default edge persistence (sidecar JSONL) --------------------------- + + @property + def _edges_path(self) -> Path: + return self.db_path / f"{self.store_name}_edges.jsonl" + + async def _persist_upsert_edges(self, path: str, edges: list[FileEdge]) -> None: + """Default: full rewrite of the edge sidecar with `path`'s row replaced.""" + snapshot = dict(self._edges) + if edges: + snapshot[path] = list(edges) + else: + snapshot.pop(path, None) + self._write_edges_jsonl(snapshot) + + async def _persist_delete_edges(self, path: str) -> None: + """Default: full rewrite of the edge sidecar omitting `path`.""" + snapshot = {p: e for p, e in self._edges.items() if p != path} + self._write_edges_jsonl(snapshot) + + def _iter_persisted_edges(self) -> Iterable[tuple[str, list[FileEdge]]]: + """Default: yield (path, edges) rows from the sidecar jsonl.""" + path = self._edges_path + if not path.exists(): + return [] + out: list[tuple[str, list[FileEdge]]] = [] + import json as _json + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + row = _json.loads(line) + edges = [FileEdge.model_validate(e) for e in row.get("edges", [])] + out.append((row["path"], edges)) + except Exception as e: + self.logger.warning(f"Bad edge row in {path}: {e}") + return out + + def _write_edges_jsonl(self, edges_by_path: dict[str, list[FileEdge]]) -> None: + import json as _json + path = self._edges_path + lines = [ + _json.dumps( + {"path": p, "edges": [e.model_dump(exclude_none=True) for e in edges]}, + ensure_ascii=False, + ) + for p, edges in edges_by_path.items() + ] + content = "\n".join(lines) + tmp = path.with_suffix(".tmp") + try: + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + except Exception as e: + self.logger.error(f"Failed to write {path}: {e}") + raise + finally: + if tmp.exists(): + tmp.unlink() + + # -- Abstract chunk APIs ------------------------------------------------ + + @abstractmethod + async def clear_all(self): + """Clear all indexed data (chunks + file metas).""" + + @abstractmethod + async def upsert_chunks(self, path: str, chunks: list[FileChunk]): + """Insert or update all chunks for a file path. Embeddings pre-attached.""" + + @abstractmethod + async def delete_chunks(self, path: str): + """Delete all chunks for a file path.""" + + @abstractmethod + async def get_chunks(self, path: str) -> list[FileChunk]: + """All chunks for a file path.""" + + @abstractmethod + async def get_chunks_by_paths(self, paths: Iterable[str]) -> list[FileChunk]: + """Batch fetch chunks across many paths (used by graph-walk retrieval).""" + + @abstractmethod + async def vector_search( + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, + ) -> list[FileChunk]: + """Vector similarity search.""" + + @abstractmethod + async def keyword_search( + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, + ) -> list[FileChunk]: + """Full-text/keyword search.""" diff --git a/reme2/component/chunk_store/local_chunk_store.py b/reme2/component/file_store/local_file_store.py similarity index 64% rename from reme2/component/chunk_store/local_chunk_store.py rename to reme2/component/file_store/local_file_store.py index 062758e8..8a95b442 100644 --- a/reme2/component/chunk_store/local_chunk_store.py +++ b/reme2/component/file_store/local_file_store.py @@ -1,19 +1,32 @@ -"""Pure-Python chunk storage with JSONL persistence.""" +"""Pure-Python in-memory store with on-close JSONL persistence. + +Design (per project clarification): + - During runtime, ALL state lives in memory — meta + edges + chunks. + Per-write disk I/O is suppressed; `_persist_upsert_meta` / `_persist_upsert_edges` + are no-ops so each upsert_* costs only a dict mutation. + - On `_start`, load the JSONL sidecars into memory. + - On `_close`, flush the full in-memory snapshot back to JSONL. + +Trade-off: lower write latency, but a hard crash drops anything since +the last clean shutdown. For larger / write-critical workloads, use +`SqliteFileStore` instead (per-write transaction). +""" import json from pathlib import Path +from typing import Iterable import numpy as np -from .base_chunk_store import BaseChunkStore +from .base_file_store import BaseFileStore from ..component_registry import R -from ...schema import ChunkFilter, FileChunk +from ...schema import ChunkFilter, FileChunk, FileEdge, FileMetadata from ...utils import batch_cosine_similarity @R.register("local") -class LocalChunkStore(BaseChunkStore): - """In-memory chunk storage with JSONL disk persistence.""" +class LocalFileStore(BaseFileStore): + """In-memory chunk + meta + edge store with deferred JSONL persistence.""" def __init__(self, encoding: str = "utf-8", **kwargs): super().__init__(**kwargs) @@ -24,7 +37,6 @@ class LocalChunkStore(BaseChunkStore): # -- Persistence helpers ------------------------------------------------ async def _load_chunks(self) -> None: - """Load chunks from JSONL file into memory.""" if not self._chunks_file.exists(): return try: @@ -39,7 +51,6 @@ class LocalChunkStore(BaseChunkStore): self.logger.warning(f"Failed to load chunks: {e}") async def _save_chunks(self) -> None: - """Persist chunks to JSONL file with atomic write.""" lines = [json.dumps(c.model_dump(mode="json"), ensure_ascii=False) for c in self._chunks.values()] content = "\n".join(lines) temp_path = self._chunks_file.with_suffix(".tmp") @@ -56,30 +67,52 @@ class LocalChunkStore(BaseChunkStore): # -- Lifecycle ---------------------------------------------------------- async def _start(self) -> None: - """Load persisted data into memory.""" await self._load_chunks() - self.logger.info(f"LocalChunkStore '{self.store_name}' ready: {len(self._chunks)} chunks") + # Base loads metas + edges from sidecar JSONL via the default + # _iter_persisted_metas / _iter_persisted_edges implementations. await super()._start() + self.logger.info( + f"LocalFileStore '{self.store_name}' ready: " + f"{len(self._nodes)} files, {sum(len(v) for v in self._edges.values())} edges, " + f"{len(self._chunks)} chunks", + ) async def _close(self) -> None: - """Flush state to disk and clear memory.""" - await self._save_chunks() + # Flush the full in-memory snapshot before tearing down state. + try: + await self._save_chunks() + self._write_metas_jsonl(self._nodes.values()) + self._write_edges_jsonl(self._edges) + except Exception as e: + self.logger.error(f"Failed to flush LocalFileStore '{self.store_name}': {e}") self._chunks.clear() await super()._close() + # -- Persistence overrides: no-op (we flush on close) ------------------- + + async def _persist_upsert_meta(self, meta: FileMetadata) -> None: + pass + + async def _persist_delete_meta(self, path: str) -> None: + pass + + async def _persist_upsert_edges(self, path: str, edges: list[FileEdge]) -> None: + pass + + async def _persist_delete_edges(self, path: str) -> None: + pass + # -- Write operations --------------------------------------------------- async def upsert_chunks(self, path: str, chunks: list[FileChunk]) -> None: - """Insert or update a file and its chunks.""" + """Insert or update a file's chunks. Chunks arrive embedded.""" await self.delete_chunks(path) if not chunks: return - chunks = await self.get_chunk_embeddings(chunks) for chunk in chunks: self._chunks[chunk.id] = chunk async def delete_chunks(self, path: str) -> None: - """Delete a file and all its chunks.""" to_delete = [cid for cid, chunk in self._chunks.items() if chunk.path == path] for cid in to_delete: del self._chunks[cid] @@ -87,20 +120,26 @@ class LocalChunkStore(BaseChunkStore): # -- Read operations ---------------------------------------------------- async def get_chunks(self, path: str) -> list[FileChunk]: - """Get all chunks for a file, sorted by start_line.""" chunks = [chunk for chunk in self._chunks.values() if chunk.path == path] chunks.sort(key=lambda c: c.start_line) return chunks + async def get_chunks_by_paths(self, paths: Iterable[str]) -> list[FileChunk]: + wanted = set(paths) + if not wanted: + return [] + chunks = [c for c in self._chunks.values() if c.path in wanted] + chunks.sort(key=lambda c: (c.path, c.start_line)) + return chunks + # -- Search operations -------------------------------------------------- async def vector_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, ) -> list[FileChunk]: - """Cosine-similarity vector search over in-memory embeddings.""" if not self.vector_enabled or not query: return [] @@ -147,12 +186,11 @@ class LocalChunkStore(BaseChunkStore): return results[:limit] async def keyword_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, ) -> list[FileChunk]: - """Keyword search via substring matching.""" if not self.fts_enabled or not query: return [] @@ -182,7 +220,13 @@ class LocalChunkStore(BaseChunkStore): return results[:limit] async def clear_all(self) -> None: - """Clear all indexed data from memory and disk.""" + """Clear all in-memory state and on-disk JSONL sidecars.""" self._chunks.clear() + self._nodes.clear() + self._edges.clear() + self._stems.clear() + self._backlinks.clear() await self._save_chunks() - self.logger.info(f"Cleared all data from LocalChunkStore '{self.store_name}'") + self._write_metas_jsonl([]) + self._write_edges_jsonl({}) + self.logger.info(f"Cleared all data from LocalFileStore '{self.store_name}'") diff --git a/reme2/component/chunk_store/sqlite_chunk_store.py b/reme2/component/file_store/sqlite_file_store.py similarity index 58% rename from reme2/component/chunk_store/sqlite_chunk_store.py rename to reme2/component/file_store/sqlite_file_store.py index bb01dd86..2fa851eb 100644 --- a/reme2/component/chunk_store/sqlite_chunk_store.py +++ b/reme2/component/file_store/sqlite_file_store.py @@ -1,22 +1,24 @@ -"""SQLite chunk storage backend.""" +"""SQLite file store backend (chunks + file metadata).""" import json import sqlite3 import struct import time +from typing import Iterable -from .base_chunk_store import BaseChunkStore +from .base_file_store import BaseFileStore from ..component_registry import R -from ...schema import ChunkFilter, FileChunk +from ...schema import ChunkFilter, FileChunk, FileEdge, FileMetadata @R.register("sqlite") -class SqliteChunkStore(BaseChunkStore): - """SQLite chunk storage with vector and full-text search. +class SqliteFileStore(BaseFileStore): + """SQLite file store with vector + full-text search. Uses sqlite-vec for vector similarity search and FTS5 with trigram - tokenizer for keyword search. Falls back to LIKE-based substring - search for short query terms. + tokenizer for keyword search. File metadata lives in a relational + table (`files_{store_name}`) with native UPSERT — overrides the + base class's default JSONL sidecar. """ def __init__(self, vec_ext_path: str = "", **kwargs): @@ -38,13 +40,21 @@ class SqliteChunkStore(BaseChunkStore): def fts_table(self) -> str: return f"chunks_fts_{self.store_name}" + @property + def files_table(self) -> str: + return f"files_{self.store_name}" + + @property + def edges_table(self) -> str: + return f"edges_{self.store_name}" + @staticmethod def vector_to_blob(embedding: list[float]) -> bytes: return struct.pack(f"{len(embedding)}f", *embedding) # -- Lifecycle ---------------------------------------------------------- - async def _start(self, app_context=None) -> None: + async def _start(self) -> None: self.conn = sqlite3.connect(self.db_path / "reme.db", check_same_thread=False) if self.vector_enabled: @@ -84,13 +94,14 @@ class SqliteChunkStore(BaseChunkStore): self.conn.enable_load_extension(False) await self._create_tables() - self.logger.info(f"SqliteChunkStore '{self.store_name}' ready: db={self.db_path / 'reme.db'}") - await super()._start(app_context) + self.logger.info(f"SqliteFileStore '{self.store_name}' ready: db={self.db_path / 'reme.db'}") + await super()._start() async def _create_tables(self) -> None: cursor = self.conn.cursor() try: - cursor.execute(f""" + cursor.execute( + f""" CREATE TABLE IF NOT EXISTS {self.chunks_table} ( id TEXT PRIMARY KEY, path TEXT, @@ -101,23 +112,26 @@ class SqliteChunkStore(BaseChunkStore): embedding TEXT, updated_at INTEGER ) - """) + """, + ) cursor.execute( - f"CREATE INDEX IF NOT EXISTS idx_{self.chunks_table}_path " - f"ON {self.chunks_table}(path)", + f"CREATE INDEX IF NOT EXISTS idx_{self.chunks_table}_path " f"ON {self.chunks_table}(path)", ) if self.vector_enabled: - cursor.execute(f""" + cursor.execute( + f""" CREATE VIRTUAL TABLE IF NOT EXISTS {self.vector_table} USING vec0( id TEXT PRIMARY KEY, embedding FLOAT[{self.embedding_dim}] ) - """) + """, + ) self.logger.info(f"Created vector table (dims={self.embedding_dim})") if self.fts_enabled: - cursor.execute(f""" + cursor.execute( + f""" CREATE VIRTUAL TABLE IF NOT EXISTS {self.fts_table} USING fts5( text, id UNINDEXED, @@ -126,9 +140,30 @@ class SqliteChunkStore(BaseChunkStore): end_line UNINDEXED, tokenize='trigram' ) - """) + """, + ) self.logger.info("Created FTS5 table with trigram tokenizer") + cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.files_table} ( + path TEXT PRIMARY KEY, + file TEXT, + st_mtime REAL, + metadata TEXT + ) + """, + ) + + cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.edges_table} ( + path TEXT PRIMARY KEY, + edges TEXT + ) + """, + ) + self.conn.commit() except Exception as e: self.logger.error(f"Failed to create tables: {e}") @@ -150,7 +185,8 @@ class SqliteChunkStore(BaseChunkStore): cursor.execute("BEGIN") old_ids = [ - row[0] for row in cursor.execute( + row[0] + for row in cursor.execute( f"SELECT id FROM {self.chunks_table} WHERE path = ?", (path,), ).fetchall() @@ -165,7 +201,6 @@ class SqliteChunkStore(BaseChunkStore): cursor.execute(f"DELETE FROM {self.fts_table} WHERE path = ?", (path,)) if chunks: - chunks = await self.get_chunk_embeddings(chunks) now = int(time.time() * 1000) for chunk in chunks: cursor.execute( @@ -212,8 +247,10 @@ class SqliteChunkStore(BaseChunkStore): cursor.execute("BEGIN") chunk_ids = [ - row[0] for row in cursor.execute( - f"SELECT id FROM {self.chunks_table} WHERE path = ?", (path,), + row[0] + for row in cursor.execute( + f"SELECT id FROM {self.chunks_table} WHERE path = ?", + (path,), ).fetchall() ] @@ -254,15 +291,17 @@ class SqliteChunkStore(BaseChunkStore): embedding = json.loads(emb_str) except (json.JSONDecodeError, TypeError): pass - chunks.append(FileChunk( - id=chunk_id, - path=path_val, - start_line=start, - end_line=end, - text=text, - hash=hash_val, - embedding=embedding, - )) + chunks.append( + FileChunk( + id=chunk_id, + path=path_val, + start_line=start, + end_line=end, + text=text, + hash=hash_val, + embedding=embedding, + ), + ) return chunks except Exception as e: self.logger.error(f"Failed to get chunks for {path}: {e}") @@ -270,13 +309,62 @@ class SqliteChunkStore(BaseChunkStore): finally: cursor.close() + async def get_chunks_by_paths(self, paths: Iterable[str]) -> list[FileChunk]: + """Batch fetch chunks across many paths. + + Splits into ≤900-placeholder batches to stay under sqlite's + SQLITE_MAX_VARIABLE_NUMBER (default 999, leave headroom). + """ + wanted = list({p for p in paths}) + if not wanted: + return [] + + cursor = self.conn.cursor() + try: + chunks: list[FileChunk] = [] + BATCH = 900 + for offset in range(0, len(wanted), BATCH): + batch = wanted[offset : offset + BATCH] + placeholders = ",".join("?" * len(batch)) + cursor.execute( + f"""SELECT id, path, start_line, end_line, text, hash, embedding + FROM {self.chunks_table} WHERE path IN ({placeholders}) + ORDER BY path, start_line""", + batch, + ) + for row in cursor.fetchall(): + chunk_id, path_val, start_line, end, text, hash_val, emb_str = row + embedding = None + if emb_str: + try: + embedding = json.loads(emb_str) + except (json.JSONDecodeError, TypeError): + pass + chunks.append( + FileChunk( + id=chunk_id, + path=path_val, + start_line=start_line, + end_line=end, + text=text, + hash=hash_val, + embedding=embedding, + ), + ) + return chunks + except Exception as e: + self.logger.error(f"Failed to batch get chunks ({len(wanted)} paths): {e}") + return [] + finally: + cursor.close() + # -- Search helpers ----------------------------------------------------- @staticmethod def _sanitize_fts_query(query: str) -> str: if not query: return "" - special_chars = list('*?:^()[]{}\'"`|+-=<>!@#$%&\\/,;') + special_chars = list("*?:^()[]{}'\"`|+-=<>!@#$%&\\/,;") cleaned = query for ch in special_chars: cleaned = cleaned.replace(ch, " ") @@ -296,10 +384,10 @@ class SqliteChunkStore(BaseChunkStore): # -- Search operations -------------------------------------------------- async def vector_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, ) -> list[FileChunk]: if not self.vector_enabled or not query: return [] @@ -325,15 +413,17 @@ class SqliteChunkStore(BaseChunkStore): chunks = [] for cid, path, start, end, text, dist in cursor.fetchall(): score = max(0.0, 1.0 - dist / 2.0) - chunks.append(FileChunk( - id=cid, - path=path, - start_line=start, - end_line=end, - text=text, - hash="", - scores={"vector": score, "score": score}, - )) + chunks.append( + FileChunk( + id=cid, + path=path, + start_line=start, + end_line=end, + text=text, + hash="", + scores={"vector": score, "score": score}, + ), + ) chunks = self._apply_filter(chunks, chunk_filter) chunks.sort(key=lambda c: c.score, reverse=True) @@ -345,10 +435,10 @@ class SqliteChunkStore(BaseChunkStore): cursor.close() async def keyword_search( - self, - query: str, - limit: int, - chunk_filter: ChunkFilter | None = None, + self, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, ) -> list[FileChunk]: if not self.fts_enabled or not query: return [] @@ -391,15 +481,17 @@ class SqliteChunkStore(BaseChunkStore): chunks = [] for cid, path, start, end, text, rank in cursor.fetchall(): score = max(0.0, 1.0 / (1.0 + abs(rank))) - chunks.append(FileChunk( - id=cid, - path=path, - start_line=start, - end_line=end, - text=text, - hash="", - scores={"keyword": score, "score": score}, - )) + chunks.append( + FileChunk( + id=cid, + path=path, + start_line=start, + end_line=end, + text=text, + hash="", + scores={"keyword": score, "score": score}, + ), + ) chunks.sort(key=lambda c: c.score, reverse=True) return chunks except Exception as e: @@ -437,15 +529,17 @@ class SqliteChunkStore(BaseChunkStore): if score == 0.0: continue - chunks.append(FileChunk( - id=cid, - path=path, - start_line=start, - end_line=end, - text=text, - hash="", - scores={"keyword": score, "score": score}, - )) + chunks.append( + FileChunk( + id=cid, + path=path, + start_line=start, + end_line=end, + text=text, + hash="", + scores={"keyword": score, "score": score}, + ), + ) chunks.sort(key=lambda c: c.score, reverse=True) return chunks[:limit] @@ -466,6 +560,8 @@ class SqliteChunkStore(BaseChunkStore): cursor.execute(f"DELETE FROM {self.vector_table}") if self.fts_enabled: cursor.execute(f"DELETE FROM {self.fts_table}") + cursor.execute(f"DELETE FROM {self.files_table}") + cursor.execute(f"DELETE FROM {self.edges_table}") cursor.execute("COMMIT") except Exception as e: cursor.execute("ROLLBACK") @@ -473,4 +569,124 @@ class SqliteChunkStore(BaseChunkStore): raise finally: cursor.close() - self.logger.info(f"Cleared all data from SqliteChunkStore '{self.store_name}'") + self.logger.info(f"Cleared all data from SqliteFileStore '{self.store_name}'") + + # -- File-meta persistence (overrides default JSONL sidecar) ------------ + + async def _persist_upsert_meta(self, meta: FileMetadata) -> None: + cursor = self.conn.cursor() + try: + cursor.execute( + f"""INSERT INTO {self.files_table} + (path, file, st_mtime, metadata) + VALUES (?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + file = excluded.file, + st_mtime = excluded.st_mtime, + metadata = excluded.metadata""", + ( + meta.path, + meta.file, + meta.st_mtime, + json.dumps(meta.metadata, ensure_ascii=False, default=str), + ), + ) + self.conn.commit() + except Exception as e: + self.conn.rollback() + self.logger.error(f"Failed to upsert file meta {meta.path}: {e}") + raise + finally: + cursor.close() + + async def _persist_delete_meta(self, path: str) -> None: + cursor = self.conn.cursor() + try: + cursor.execute(f"DELETE FROM {self.files_table} WHERE path = ?", (path,)) + self.conn.commit() + except Exception as e: + self.conn.rollback() + self.logger.error(f"Failed to delete file meta {path}: {e}") + raise + finally: + cursor.close() + + def _iter_persisted_metas(self) -> Iterable[FileMetadata]: + if self.conn is None: + return [] + cursor = self.conn.cursor() + try: + cursor.execute( + f"SELECT path, file, st_mtime, metadata " + f"FROM {self.files_table}", + ) + out: list[FileMetadata] = [] + for path, file, st_mtime, metadata_json in cursor.fetchall(): + try: + out.append( + FileMetadata( + path=path, + file=file or "", + st_mtime=st_mtime or 0.0, + metadata=json.loads(metadata_json) if metadata_json else {}, + ), + ) + except Exception as e: + self.logger.warning(f"Bad file meta row for {path}: {e}") + return out + finally: + cursor.close() + + # -- Edge persistence (overrides default JSONL sidecar) ----------------- + + async def _persist_upsert_edges(self, path: str, edges: list[FileEdge]) -> None: + cursor = self.conn.cursor() + try: + if edges: + cursor.execute( + f"""INSERT INTO {self.edges_table} (path, edges) + VALUES (?, ?) + ON CONFLICT(path) DO UPDATE SET edges = excluded.edges""", + (path, json.dumps( + [e.model_dump(exclude_none=True) for e in edges], + ensure_ascii=False, + )), + ) + else: + cursor.execute(f"DELETE FROM {self.edges_table} WHERE path = ?", (path,)) + self.conn.commit() + except Exception as e: + self.conn.rollback() + self.logger.error(f"Failed to upsert edges for {path}: {e}") + raise + finally: + cursor.close() + + async def _persist_delete_edges(self, path: str) -> None: + cursor = self.conn.cursor() + try: + cursor.execute(f"DELETE FROM {self.edges_table} WHERE path = ?", (path,)) + self.conn.commit() + except Exception as e: + self.conn.rollback() + self.logger.error(f"Failed to delete edges for {path}: {e}") + raise + finally: + cursor.close() + + def _iter_persisted_edges(self) -> Iterable[tuple[str, list[FileEdge]]]: + if self.conn is None: + return [] + cursor = self.conn.cursor() + try: + cursor.execute(f"SELECT path, edges FROM {self.edges_table}") + out: list[tuple[str, list[FileEdge]]] = [] + for path, edges_json in cursor.fetchall(): + try: + raws = json.loads(edges_json) if edges_json else [] + out.append((path, [FileEdge.model_validate(e) for e in raws])) + except Exception as e: + self.logger.warning(f"Bad edge row for {path}: {e}") + return out + finally: + cursor.close() diff --git a/reme2/component/file_watcher/base_file_watcher.py b/reme2/component/file_watcher/base_file_watcher.py index d0740b79..544a8af4 100644 --- a/reme2/component/file_watcher/base_file_watcher.py +++ b/reme2/component/file_watcher/base_file_watcher.py @@ -1,82 +1,95 @@ -"""Base file watcher with watchfiles integration.""" +"""Base file watcher with watchfiles integration. + +Per change → single-step pipeline: + added/modified → existing = file_store.get_chunks(path) + parsed = parser.parse(path, existing_chunks=existing) + file_store.upsert_parsed(parsed) + deleted → file_store.delete_chunks + delete_file_meta + +The parser owns: chunking, edge extraction, and embedding (with hash-diff +cache via the `existing_chunks` argument). The file_store owns: persistence +of meta + edges + chunks (atomic-from-the-caller's-POV via `upsert_parsed`). +The watcher owns: scheduling, cancel-on-modify, retry/timeout, and +startup recovery (re-parsing files whose mtime drifted while offline). +""" import asyncio +import time from pathlib import Path from watchfiles import Change, awatch from ..base_component import BaseComponent -from ..chunk_store import BaseChunkStore from ..file_parser import BaseFileParser +from ..file_store import BaseFileStore from ...enumeration import ComponentEnum -from ...schema.file_graph import FileGraph class BaseFileWatcher(BaseComponent): - """Abstract base class for file watchers. - - Provides file monitoring with: - - watchfiles integration for efficient change detection - - Parser-based file filtering - - Auto-restart on failure - - Optional index rebuild on start - """ + """Watches a directory and feeds the file_store via single-step parse+upsert.""" component_type = ComponentEnum.FILE_WATCHER + _META_DIR = ".reme" + def __init__( - self, - watch_path: str, - recursive: bool = False, - debounce: int = 2000, - chunk_tokens: int = 400, - chunk_overlap: int = 80, - chunk_store: str = "default", - default_parser: str | None = None, - rebuild_index_on_start: bool = False, - poll_delay_ms: int = 2000, - **kwargs, + self, + watch_path: str, + recursive: bool = False, + debounce: int = 2000, + file_store: str = "default", + default_parser: str | None = None, + rebuild_index_on_start: bool = False, + poll_delay_ms: int = 2000, + parse_max_attempts: int = 3, + parse_retry_backoff: float = 2.0, + parse_task_timeout: float = 300.0, + **kwargs, ): super().__init__(**kwargs) - self._chunk_store_name: str = chunk_store + self._file_store_name: str = file_store self._default_parser_name: str | None = default_parser - self.chunk_store: BaseChunkStore | None = None + self.file_store: BaseFileStore | None = None self._suffix_to_parser: dict[str, BaseFileParser] = {} self._default_parser: BaseFileParser | None = None self.watch_path: str = watch_path self.recursive: bool = recursive self.debounce: int = debounce - self.chunk_tokens: int = chunk_tokens - self.chunk_overlap: int = chunk_overlap self.rebuild_index_on_start: bool = rebuild_index_on_start self.poll_delay_ms: int = poll_delay_ms - self.file_graph: FileGraph = FileGraph() self._stop_event = asyncio.Event() self._watch_task: asyncio.Task | None = None - _META_DIR = ".reme" + # Parse pipeline configuration. + self._parse_max_attempts: int = max(1, int(parse_max_attempts)) + self._parse_retry_backoff: float = float(parse_retry_backoff) + self._parse_task_timeout: float = float(parse_task_timeout) + self._tasks: dict[str, asyncio.Task] = {} + self._mgmt_lock: asyncio.Lock | None = None + self._run_lock: asyncio.Lock | None = None + self._last_failure: dict[str, float] = {} @property def _meta_path(self) -> Path: - return Path(self.watch_path) / self._META_DIR + return (Path(self.watch_path) / self._META_DIR).resolve() - @property - def _graph_path(self) -> Path: - return self._meta_path / "file_graph.json" + # -- Lifecycle ---------------------------------------------------------- async def _start(self): - """Resolve chunk_store, load or build file_graph, and start watching.""" - if self._chunk_store_name: + """Resolve file_store + parsers, sync any disk drift, start watch loop.""" + if self._file_store_name: assert self.app_context is not None, "app_context must be provided" - stores = self.app_context.components.get(ComponentEnum.CHUNK_STORE, {}) - if self._chunk_store_name not in stores: - raise ValueError(f"Chunk store '{self._chunk_store_name}' not found.") - store = stores[self._chunk_store_name] - if not isinstance(store, BaseChunkStore): - raise TypeError(f"Expected BaseChunkStore, got {type(store).__name__}") - self.chunk_store = store + stores = self.app_context.components.get(ComponentEnum.FILE_STORE, {}) + if self._file_store_name not in stores: + raise ValueError(f"File store '{self._file_store_name}' not found.") + store = stores[self._file_store_name] + if not isinstance(store, BaseFileStore): + raise TypeError(f"Expected BaseFileStore, got {type(store).__name__}") + self.file_store = store + # Vault root drives explicit-path wikilink resolution. + self.file_store.set_vault_root(self.watch_path) parsers = self.app_context.components.get(ComponentEnum.FILE_PARSER, {}) for parser in parsers.values(): @@ -93,28 +106,16 @@ class BaseFileWatcher(BaseComponent): self.logger.warning("No file parsers registered") async def _initialize_and_watch(): - await self._load_or_build_graph() + await self._initial_sync_and_recovery() await self._watch_loop() + self._mgmt_lock = asyncio.Lock() + self._run_lock = asyncio.Lock() + self._last_failure.clear() self._stop_event.clear() self._watch_task = asyncio.create_task(_initialize_and_watch()) self.logger.info(f"Started watching: {self.watch_path}") - async def _load_or_build_graph(self) -> None: - graph_path = self._graph_path - if not self.rebuild_index_on_start and graph_path.exists(): - self.file_graph = FileGraph.load(graph_path) - self.logger.info(f"Loaded file graph from {graph_path} ({len(self.file_graph)} nodes)") - else: - if self.rebuild_index_on_start: - self.logger.info("Rebuild on start enabled, scanning to build") - else: - self.logger.info("No persisted file graph found, scanning to build") - self.file_graph = FileGraph() - await self._scan_existing_files() - self.file_graph.save(graph_path) - self.logger.info(f"Saved file graph to {graph_path} ({len(self.file_graph)} nodes)") - async def _close(self): """Stop watching and release resources.""" self._stop_event.set() @@ -125,39 +126,94 @@ class BaseFileWatcher(BaseComponent): except asyncio.CancelledError: pass + # Cancel all in-flight parse tasks before tearing down state. + tasks = list(self._tasks.values()) + for t in tasks: + if not t.done(): + t.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._tasks.clear() + self._last_failure.clear() + self._watch_task = None self._stop_event.clear() - self.chunk_store = None + self._mgmt_lock = None + self._run_lock = None + self.file_store = None self._suffix_to_parser.clear() self._default_parser = None self.logger.info("Stopped watching") - async def _scan_existing_files(self) -> None: - if not self.chunk_store: - return + # -- Startup sync ------------------------------------------------------- + + async def _initial_sync_and_recovery(self) -> None: + """At startup: file_store has its persisted state loaded already; we + diff against current disk for any changes that happened while we + were offline, then re-enqueue them through the parse pipeline.""" + await self._sync_with_disk() + + async def _sync_with_disk(self) -> bool: + """Diff cached graph (in file_store) vs disk state; reindex changes.""" + if self.file_store is None: + return False watch_path = Path(self.watch_path) if not watch_path.exists(): self.logger.warning(f"Watch path does not exist: {watch_path}") - return + return False - existing_files: set[tuple[Change, str]] = set() + on_disk: dict[str, float] = {} if watch_path.is_file(): - existing_files.add((Change.added, str(watch_path))) + on_disk[str(watch_path.resolve())] = watch_path.stat().st_mtime elif watch_path.is_dir(): iterator = watch_path.rglob("*") if self.recursive else watch_path.iterdir() - for file_path in iterator: - if file_path.is_file() and self._watch_filter(str(file_path)): - existing_files.add((Change.added, str(file_path))) + for fp in iterator: + if not fp.is_file(): + continue + abs_path = str(fp.resolve()) + if not self._watch_filter(abs_path): + continue + if not self._get_parser(abs_path): + continue + try: + on_disk[abs_path] = fp.stat().st_mtime + except OSError: + continue - if existing_files: - self.logger.info(f"Scanning {len(existing_files)} existing files") - await self.on_changes(existing_files) - else: - self.logger.info("No existing files found") + cached_paths = set(self.file_store.nodes.keys()) + disk_paths = set(on_disk.keys()) + + added = disk_paths - cached_paths + deleted = cached_paths - disk_paths + modified: set[str] = set() + for p in disk_paths & cached_paths: + cached_meta = self.file_store.get_file_meta(p) + if cached_meta is None or cached_meta.st_mtime != on_disk[p]: + modified.add(p) + + unchanged = len(disk_paths & cached_paths) - len(modified) + if not (added or deleted or modified): + self.logger.info(f"Index up-to-date ({unchanged} files cached, 0 changes)") + return False + + self.logger.info( + f"Incremental sync: +{len(added)} ~{len(modified)} -{len(deleted)} (unchanged {unchanged})", + ) + + changes: set[tuple[Change, str]] = set() + for p in deleted: + changes.add((Change.deleted, p)) + for p in added: + changes.add((Change.added, p)) + for p in modified: + changes.add((Change.modified, p)) + await self.on_changes(changes) + return True + + # -- Watch loop --------------------------------------------------------- async def _interruptible_sleep(self, seconds: float): - """Sleep that can be interrupted by stop_event.""" try: await asyncio.wait_for(self._stop_event.wait(), timeout=seconds) except asyncio.TimeoutError: @@ -173,12 +229,12 @@ class BaseFileWatcher(BaseComponent): try: self.logger.info(f"Starting watch on: {self.watch_path}") async for changes in awatch( - self.watch_path, - watch_filter=lambda _, p: self._watch_filter(p), - recursive=self.recursive, - debounce=self.debounce, - poll_delay_ms=self.poll_delay_ms, - stop_event=self._stop_event, + self.watch_path, + watch_filter=lambda _, p: self._watch_filter(p), + recursive=self.recursive, + debounce=self.debounce, + poll_delay_ms=self.poll_delay_ms, + stop_event=self._stop_event, ): if self._stop_event.is_set(): break @@ -195,22 +251,25 @@ class BaseFileWatcher(BaseComponent): await self._interruptible_sleep(10) def _watch_filter(self, path: str) -> bool: - return self._meta_path not in Path(path).parents + resolved = Path(path).resolve() + if resolved == self._meta_path: + return False + return self._meta_path not in resolved.parents def _get_parser(self, path: str) -> BaseFileParser | None: suffix = Path(path).suffix.lower() return self._suffix_to_parser.get(suffix, self._default_parser) + # -- Change dispatch ---------------------------------------------------- + async def on_changes(self, changes: set[tuple[Change, str]]) -> None: - if not self.chunk_store: + if self.file_store is None: self.logger.warning("File store not initialized, skipping changes") return for change_type, path in changes: try: - if change_type == Change.added: - await self._on_added(path) - elif change_type == Change.modified: + if change_type in (Change.added, Change.modified): await self._on_modified(path) elif change_type == Change.deleted: await self._on_deleted(path) @@ -219,29 +278,115 @@ class BaseFileWatcher(BaseComponent): except PermissionError: self.logger.warning(f"Permission denied: {path}, skipping") except Exception as e: - self.logger.error(f"Error processing {path}: {e}", exc_info=True) - - async def _on_added(self, path: str) -> None: - parser = self._get_parser(path) - if not parser: - self.logger.debug(f"No parser for {path}, skipping") - return - file_meta, chunks = await parser.parse(path) - await self.chunk_store.upsert_chunks(path, chunks) - self.file_graph.create(file_meta) - self.logger.info(f"Added {path} ({len(chunks)} chunks)") + self.logger.opt(exception=True).error("Error processing {p}: {err}", p=path, err=str(e)) async def _on_modified(self, path: str) -> None: + if not Path(path).is_file(): + return parser = self._get_parser(path) - if not parser: + if parser is None: self.logger.debug(f"No parser for {path}, skipping") return - file_meta, chunks = await parser.parse(path) - await self.chunk_store.upsert_chunks(path, chunks) - self.file_graph.create(file_meta) - self.logger.info(f"Modified {path} ({len(chunks)} chunks)") + await self._submit_parse_task(path, parser) async def _on_deleted(self, path: str) -> None: - await self.chunk_store.delete_chunks(path) - self.file_graph.delete(path) + # Cancel any in-flight parse task FIRST so a delayed cancellation + # can't race the cleanup writes below. + assert self.file_store is not None, "_on_deleted requires file_store" + await self._cancel_parse_task(path) + await self.file_store.delete_chunks(path) + await self.file_store.delete_file_meta(path) self.logger.info(f"Deleted {path}") + + # -- Parse task pipeline ------------------------------------------------ + + async def _submit_parse_task(self, path: str, parser: BaseFileParser) -> None: + """Cancel any prior task + spawn a new one. Atomic under _mgmt_lock.""" + assert self._mgmt_lock is not None, "_submit_parse_task before _start()" + async with self._mgmt_lock: + await self._cancel_locked(path) + self._tasks[path] = asyncio.create_task( + self._run_parse_task(path, parser), + ) + + async def _cancel_parse_task(self, path: str) -> None: + """Cancel + await any in-flight parse task for `path`.""" + assert self._mgmt_lock is not None, "_cancel_parse_task before _start()" + async with self._mgmt_lock: + await self._cancel_locked(path) + + async def _cancel_locked(self, path: str) -> None: + """Cancel + await the task for `path`. Caller holds `_mgmt_lock`.""" + task = self._tasks.pop(path, None) + if task is not None and not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + async def flush_parse_tasks(self) -> None: + """Wait until all parse tasks finish (loops because retries spawn new ones).""" + while self._tasks: + await asyncio.gather(*list(self._tasks.values()), return_exceptions=True) + + def pending_parse_count(self) -> int: + return sum(1 for t in self._tasks.values() if not t.done()) + + def failed_parse_paths(self) -> dict[str, float]: + return dict(self._last_failure) + + async def _run_parse_task(self, path: str, parser: BaseFileParser) -> None: + """One per-path parse task: get prior chunks → parse → upsert_parsed. + + Hash diff happens inside the parser via `existing_chunks`: chunks + whose hash matches a stored chunk reuse the prior embedding; only + new-hash blocks hit the embedding API. The whole upsert is one + call to the file_store. + """ + try: + for attempt in range(1, self._parse_max_attempts + 1): + try: + assert self.file_store is not None + existing = await self.file_store.get_chunks(path) + parsed = await asyncio.wait_for( + parser.parse(path, existing_chunks=existing), + timeout=self._parse_task_timeout, + ) + + assert self._run_lock is not None + async with self._run_lock: + await asyncio.wait_for( + self.file_store.upsert_parsed(parsed), + timeout=self._parse_task_timeout, + ) + self._last_failure.pop(path, None) + + self.logger.info( + f"indexed {path}: chunks={len(parsed.chunks)} edges={len(parsed.edges)}", + ) + return + except asyncio.CancelledError: + raise + except FileNotFoundError: + self.logger.debug(f"parse task {path}: file gone, skipping") + self._last_failure.pop(path, None) + return + except Exception as e: + self._last_failure[path] = time.time() + if attempt < self._parse_max_attempts: + backoff = self._parse_retry_backoff * attempt + self.logger.warning( + f"parse task {path} attempt {attempt} failed " + f"({type(e).__name__}: {e}); retry in {backoff:.1f}s", + ) + await asyncio.sleep(backoff) + continue + self.logger.error( + f"parse task {path} giving up after {attempt} attempts: " + f"{type(e).__name__}: {e}", + ) + return + finally: + if self._tasks.get(path) is asyncio.current_task(): + self._tasks.pop(path, None) diff --git a/reme2/component/job/base_job.py b/reme2/component/job/base_job.py index ea6766e3..177467cb 100644 --- a/reme2/component/job/base_job.py +++ b/reme2/component/job/base_job.py @@ -1,4 +1,5 @@ """Base job component for sequential step execution.""" + from typing import TYPE_CHECKING from ..base_component import BaseComponent @@ -18,11 +19,11 @@ class BaseJob(BaseComponent): component_type = ComponentEnum.JOB def __init__( - self, - description: str = "", - parameters: dict | None = None, - steps: list[ComponentConfig] | None = None, - **kwargs, + self, + description: str = "", + parameters: dict | None = None, + steps: list[ComponentConfig] | None = None, + **kwargs, ): super().__init__(**kwargs) self.description = description @@ -32,14 +33,14 @@ class BaseJob(BaseComponent): async def _start(self) -> None: assert self.app_context is not None, "app_context must be provided" - for config in self.step_configs: + for raw in self.step_configs: + config = raw if isinstance(raw, ComponentConfig) else ComponentConfig(**raw) if not config.backend: raise ValueError(f"Step is missing the required 'backend' field") step_cls = R.get(ComponentEnum.STEP, config.backend) if not step_cls: raise ValueError( - f"Step references an unregistered backend '{config.backend}' " - f"of type '{ComponentEnum.STEP}'", + f"Step references an unregistered backend '{config.backend}' " f"of type '{ComponentEnum.STEP}'", ) params = config.model_dump() params["app_context"] = self.app_context diff --git a/reme2/component/prompt_handler.py b/reme2/component/prompt_handler.py index 891ce242..575c82b1 100644 --- a/reme2/component/prompt_handler.py +++ b/reme2/component/prompt_handler.py @@ -21,9 +21,9 @@ class PromptHandler: self.language: str = language.strip() def load_prompt_by_file( - self, - prompt_file_path: str | Path | None = None, - overwrite: bool = True, + self, + prompt_file_path: str | Path | None = None, + overwrite: bool = True, ) -> "PromptHandler": """Load prompts from a YAML or JSON file.""" if prompt_file_path is None: diff --git a/reme2/component/runtime_response.py b/reme2/component/runtime_response.py new file mode 100644 index 00000000..92b2732f --- /dev/null +++ b/reme2/component/runtime_response.py @@ -0,0 +1,30 @@ +"""Helpers for serializing Step output onto `RuntimeContext.response`. + +Lives next to `runtime_context.py` because both are about the BaseStep +interface — the response side, specifically. Used by every Step that +returns a JSON-shaped payload (memory_*, sync, topic_create, the three +memory services). + +Was previously at `reme2/mcp/steps/_common.py`, which leaked an MCP +dependency into `reme2/memory/` services that legitimately need to +serialize their results — the moved location breaks that cycle. +""" + +from __future__ import annotations + +import json +from datetime import date, datetime + + +def _to_jsonable(value): + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_to_jsonable(v) for v in value] + return value + + +def _set_answer(context, payload) -> None: + context.response.answer = json.dumps(_to_jsonable(payload), ensure_ascii=False, indent=2) diff --git a/reme2/component/service/base_service.py b/reme2/component/service/base_service.py index e834ffc4..0f39820a 100644 --- a/reme2/component/service/base_service.py +++ b/reme2/component/service/base_service.py @@ -21,16 +21,13 @@ class BaseService(BaseComponent): self.service = None @abstractmethod - def build_service(self, app: "Application") -> None: - ... + def build_service(self, app: "Application") -> None: ... @abstractmethod - def add_job(self, job: BaseJob) -> None: - ... + def add_job(self, job: BaseJob) -> None: ... @abstractmethod - def start_service(self, app: "Application") -> None: - ... + def start_service(self, app: "Application") -> None: ... def add_jobs(self, app: "Application") -> None: for name, job in app.context.jobs.items(): diff --git a/reme2/component/service/http_service.py b/reme2/component/service/http_service.py index 9b2d2762..5c4bef60 100644 --- a/reme2/component/service/http_service.py +++ b/reme2/component/service/http_service.py @@ -51,10 +51,10 @@ class HttpService(BaseService): 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", + stream_queue=stream_queue, + task=task, + task_name=job.name, + output_format="bytes", ): assert isinstance(chunk, bytes) yield chunk diff --git a/reme2/component/service/mcp_service.py b/reme2/component/service/mcp_service.py index c662dee5..cabe3257 100644 --- a/reme2/component/service/mcp_service.py +++ b/reme2/component/service/mcp_service.py @@ -1,9 +1,20 @@ -"""Model Context Protocol (MCP) service implementation.""" +"""Model Context Protocol (MCP) service implementation. + +Optionally exposes an HTTP sidecar (off by default) on a local-only +loopback port. The sidecar shares the running Application instance, +so callers (lifecycle hooks, scripts) see the live file_store without +booting a duplicate process. +""" + +import asyncio import json import os from contextlib import asynccontextmanager +from pathlib import Path from typing import TYPE_CHECKING +import uvicorn +from fastapi import FastAPI, Request as FastAPIRequest from fastmcp import FastMCP from fastmcp.server.server import Transport from fastmcp.tools import FunctionTool @@ -18,20 +29,54 @@ if TYPE_CHECKING: from ..job import BaseJob +def _build_sidecar_app(app: "Application") -> FastAPI: + """A minimal FastAPI app that exposes each registered job as POST /.""" + api = FastAPI(title=f"{app.config.app_name}-sidecar") + + @api.get("/health") + async def health(): + return {"status": "ok", "jobs": list(app.context.jobs.keys())} + + for job_name in list(app.context.jobs.keys()): + # bind job_name via default arg to avoid late-binding in the closure + async def endpoint(request: FastAPIRequest, _name: str = job_name): + try: + payload = await request.json() + except Exception: + payload = {} + response = await app.run_job(_name, **(payload or {})) + return {"answer": response.answer, "success": response.success} + + api.post(f"/{job_name}")(endpoint) + + return api + + @R.register("mcp") class MCPService(BaseService): """Expose jobs as Model Context Protocol (MCP) tools.""" def __init__( - self, - transport: Transport = "sse", - host: str = REME_DEFAULT_HOST, - port: int = REME_DEFAULT_PORT, - **kwargs): + self, + transport: Transport = "sse", + host: str = REME_DEFAULT_HOST, + port: int = REME_DEFAULT_PORT, + sidecar_http: bool = False, + sidecar_http_host: str = "127.0.0.1", + sidecar_http_port: int = 8765, + sidecar_info_path: str = "", + **kwargs, + ): super().__init__(**kwargs) self.transport: Transport = transport self.host: str = host self.port: int = port + self.sidecar_http: bool = sidecar_http + self.sidecar_http_host: str = sidecar_http_host + self.sidecar_http_port: int = sidecar_http_port + # Optional file path where {host, port} are dropped at startup so that + # external scripts (lifecycle hooks) can discover the sidecar URL. + self.sidecar_info_path: str = sidecar_info_path def build_service(self, app: "Application") -> None: @@ -41,11 +86,59 @@ class MCPService(BaseService): 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() + + sidecar_task: asyncio.Task | None = None + if self.sidecar_http: + sidecar_task = asyncio.create_task(self._serve_sidecar(app)) + + try: + yield + finally: + if sidecar_task is not None: + sidecar_task.cancel() + try: + await sidecar_task + except (asyncio.CancelledError, Exception): + pass + self._cleanup_sidecar_info() + await app.close() self.service = FastMCP(name=app.config.app_name, lifespan=lifespan) + async def _serve_sidecar(self, app: "Application") -> None: + sidecar_app = _build_sidecar_app(app) + config = uvicorn.Config( + sidecar_app, + host=self.sidecar_http_host, + port=self.sidecar_http_port, + log_level="warning", + access_log=False, + ) + server = uvicorn.Server(config) + self._publish_sidecar_info() + self.logger.info( + f"MCP HTTP sidecar listening on " f"http://{self.sidecar_http_host}:{self.sidecar_http_port}", + ) + await server.serve() + + def _publish_sidecar_info(self) -> None: + if not self.sidecar_info_path: + return + info_path = Path(self.sidecar_info_path) + info_path.parent.mkdir(parents=True, exist_ok=True) + info_path.write_text( + json.dumps({"host": self.sidecar_http_host, "port": self.sidecar_http_port}), + encoding="utf-8", + ) + + def _cleanup_sidecar_info(self) -> None: + if not self.sidecar_info_path: + return + try: + Path(self.sidecar_info_path).unlink(missing_ok=True) + except Exception: + pass + def add_job(self, job: "BaseJob") -> None: if isinstance(job, StreamJob): return diff --git a/reme2/config/config_parser.py b/reme2/config/config_parser.py index c4a0d7d5..72f8cb07 100644 --- a/reme2/config/config_parser.py +++ b/reme2/config/config_parser.py @@ -1,6 +1,8 @@ """Parser for YAML config with CLI argument overrides.""" import json +import os +import re from pathlib import Path from typing import Any @@ -9,6 +11,32 @@ import yaml # Config files are looked up relative to this module's directory _CONFIG_DIR = Path(__file__).parent _SUPPORTED_EXTS = (".yaml", ".yml", ".json") +_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") + + +def _expand_env_vars(value: Any) -> Any: + """Recursively expand `${VAR}` / `${VAR:-default}` placeholders in strings. + + Lets vault.yaml reference secrets / per-host settings without baking + them into the file. Unset vars without a default raise ValueError so + typos don't silently produce empty connection strings. + """ + if isinstance(value, str): + def repl(m: re.Match) -> str: + name = m.group(1) + default = 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 + 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 # Pre-scan config directory: maps basename(without ext) -> Path @@ -91,14 +119,15 @@ def _load_yaml(name_or_path: str, encoding: str = "utf-8") -> dict: def _read_config_file(path: Path, encoding: str = "utf-8") -> dict: - """Read YAML or JSON file based on extension.""" + """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) - return result if result is not None else {} else: result = yaml.safe_load(f) - return result if result is not None else {} + if result is None: + return {} + return _expand_env_vars(result) def _deep_merge(base: dict, update: dict) -> dict: diff --git a/reme2/config/curated.yaml b/reme2/config/curated.yaml new file mode 100644 index 00000000..37ac2343 --- /dev/null +++ b/reme2/config/curated.yaml @@ -0,0 +1,220 @@ +app_name: reme-curated +enable_logo: false +log_to_console: true +log_to_file: false + +# Curated profile: three MCP tools surfaced — +# `query` — graph-aware hybrid retrieval +# `sync` — hot-path event sync (idempotent upsert per (date, name)) +# `ingest` — LLM-driven distillation over existing files (cold path) +# +# The agent calls `sync` continuously through the task — picking a +# stable `name` per logical thread so each call extends the same +# event folder rather than fragmenting. At task completion (or +# PreCompact / SessionEnd) it calls `ingest` once to distill the +# active events into topic-level cognition. +# +# For full direct control over every memory_* primitive see ./full.yaml. + +service: + backend: mcp + transport: stdio + sidecar_http: true + sidecar_http_host: "127.0.0.1" + sidecar_http_port: 8765 + sidecar_info_path: "./vault/.reme/sidecar.json" + +jobs: + - backend: base + name: query + description: | + Graph-aware hybrid retrieval: vector + keyword + 1-hop wikilink + BFS fusion. Use for "what do I know about X" / "did I work on Y" / + "what's connected to [[Z]]". Anchor mode: include `[[Target]]` in + the query to seed BFS at that file. Topic-rooted mode: pass `seeds` + explicitly. Returns chunks ranked by combined relevance + graph + proximity, each tagged with `graph_hop`. + parameters: + type: object + properties: + query: { type: string } + max_results: { type: integer, default: 5 } + min_score: { type: number, default: 0.0 } + graph_depth: + type: integer + default: 1 + description: "BFS hops from seeds. 1 covers immediate neighbors." + seeds: + type: array + items: { type: string } + description: "Explicit seed paths (topic-rooted mode)." + paths: { type: array, items: { type: string } } + tags: { type: array, items: { type: string } } + exclude_paths: { type: array, items: { type: string } } + steps: + - backend: memory_graph_search + + - backend: base + name: sync + description: | + Hot-path event sync: idempotent upsert of an event FOLDER under + `events/{date}/{name}/`. The folder contains the index `{name}.md` + (Event schema, frontmatter + narrative + Materials footer) plus + any raw materials you pass — conversation snippets, tool outputs, + data dumps. The watcher indexes everything inside. + + CONTINUITY MODEL: pick a stable `name` per logical thread and + call `sync` repeatedly through the task. Each call extends the + same folder: + * new `content` → appended under a `## Update — {iso}` section + * new `materials` → siblings (auto-suffix on filename collision) + * `topics` + `tags` merged (union) into frontmatter + * Materials footer regenerated to list every artifact in the folder + + First call (folder doesn't exist) → CREATE; subsequent calls with + the same `name` while the event is `status: active` → APPEND. + If the event is `status: distilled` / `archived`, `sync` REFUSES + and returns a `suggested_name` so you start a fresh thread instead + of mutating prior cognition. + + Zero LLM cost. Call CONTINUOUSLY through a task as facts land, + and especially at PreCompact to dump verbose raw text into + `materials` before context truncation loses it. The folder is + the unit `ingest` later reads from. + parameters: + type: object + required: [name] + properties: + name: + type: string + description: "kebab-case event identifier (folder + index stem). Reuse the same name across calls in one thread to keep extending the same folder." + description: { type: string, description: "one-line summary for index frontmatter (set on initial create only)" } + content: { type: string, description: "markdown body. Initial create: the body. Subsequent calls: appended as a `## Update — {iso}` section." } + topics: + type: array + items: { type: string } + description: "related topic wikilinks. Unioned into frontmatter on append." + tags: { type: array, items: { type: string }, description: "free-form tags; unioned on append." } + materials: + type: array + description: "Raw artifacts written as siblings of the index. Filenames must be safe (letters/digits/dot/underscore/dash). Filename collision with an existing artifact auto-suffixes (foo.txt → foo-2.txt)." + items: + type: object + required: [filename, content] + properties: + filename: { type: string, description: "e.g. 'raw-prompt.md', 'tool-output.txt'" } + content: { type: string } + on_date: { type: string, description: "ISO date for events/{date}/ bucket; defaults to today" } + origin_session_id: { type: string, description: "set on initial create only" } + steps: + - backend: sync + + - backend: base + name: ingest + description: | + Cold-path LLM-driven distillation. Run on EXPLICIT HANDOFF only: + task completion / SessionEnd / when the agent decides the working + set is ready. Not a per-turn tool. + + The agent hands off the working set in two interchangeable forms + (use both as appropriate): + * `content` — inline material to distill: a hint / summary, or + raw text the agent is feeding directly. + * `related_paths` — paths the agent points at: event folder + indexes (Ingestor follows `## Materials` to read each + artifact), individual material files, or candidate topics. + + The Ingestor reads the working set + linked topics, decides which + existing topics to update / create, and flips each distilled + event's status to "distilled". Returns an audit trail. + + NOT for raw event logging — that's `sync`'s job. + parameters: + type: object + required: [content] + properties: + content: + type: string + description: "Inline material the agent is feeding the Ingestor: distillation hint, session summary, or raw text. Combined with `related_paths` to form the working set." + hint: + type: string + description: "Caller guidance about target / intent." + target_path: + type: string + description: "Optional suggested path; required for the no-LLM degraded path." + metadata: + type: object + description: "Suggested frontmatter for any new topic." + related_paths: + type: array + items: { type: string } + description: "Pointers the agent is feeding the Ingestor: event folder indexes (Ingestor reads materials from `## Materials`), individual material files, or candidate topics." + steps: + - backend: ingestor + +components: + # Ingestor LLM (opt-in). Without this the Ingestor degrades to a + # direct create from explicit `target_path`; edits/renames/deletes + # require the LLM. Uncomment + provide LLM_API_KEY to enable. + # + # as_llm: + # default: + # backend: openai + # model_name: ${LLM_MODEL_NAME:-gpt-4o-mini} + # api_key: ${LLM_API_KEY} + # client_kwargs: + # base_url: ${LLM_BASE_URL:-https://api.openai.com/v1} + # stream: false + # + # as_llm_formatter: + # default: + # backend: openai + + as_token_counter: + default: + backend: estimated + + # Embedding is opt-in: leave embedding_model="" on file_store to run + # keyword-only; uncomment + flip to "default" to enable hybrid search. + # + # embedding_model: + # default: + # backend: openai + # model_name: ${EMBEDDING_MODEL_NAME:-text-embedding-3-small} + # dimensions: 1536 + # pass_dimensions: false + # enable_cache: true + # max_batch_size: 10 + # max_cache_size: 2000 + # max_input_length: 8192 + + edge_extractor: + default: + backend: regex + + file_parser: + md: + backend: md + edge_extractor: default + default: + backend: text + + file_store: + default: + backend: local + embedding_model: "" + store_name: "reme" + db_path: "./vault/.reme" + fts_enabled: true + + file_watcher: + default: + backend: full + file_store: default + default_parser: md + watch_path: "./vault" + recursive: true + + # Retriever (`hybrid`) is a Step, not a pre-instantiated component — + # the `query` shell builds it on demand. Tune defaults by attaching + # knobs to the `memory_graph_search` step under the `query` job above. diff --git a/reme2/config/full.yaml b/reme2/config/full.yaml new file mode 100644 index 00000000..7630371e --- /dev/null +++ b/reme2/config/full.yaml @@ -0,0 +1,409 @@ +app_name: reme-full +enable_logo: false +log_to_console: true +log_to_file: false + +# Full-exposure profile: every memory_* read + write primitive + the +# typed `topic_create` + the LLM-driven `ingest` are surfaced as MCP +# tools. The agent picks whatever it needs — finest granularity, no +# opinion enforced beyond the wikilink-uniqueness gate. +# +# Use when you want the agent to manage memory directly with full +# control. For an opinionated minimal surface, see ./curated.yaml +# (only `query` + `ingest`). + +service: + backend: mcp + transport: stdio + sidecar_http: true + sidecar_http_host: "127.0.0.1" + sidecar_http_port: 8765 + sidecar_info_path: "./vault/.reme/sidecar.json" + +jobs: + # -- Write entry points ------------------------------------------------ + + - backend: base + name: sync + description: | + Hot-path event sync: idempotent upsert of an event FOLDER under + `events/{date}/{name}/`. The folder contains the index `{name}.md` + (Event schema, frontmatter + narrative + Materials footer) plus + any raw materials you pass — conversation snippets, tool outputs, + data dumps. The watcher indexes everything inside. + + CONTINUITY MODEL: pick a stable `name` per logical thread and + call `sync` repeatedly through the task. Each call extends the + same folder: + * new `content` → appended under a `## Update — {iso}` section + * new `materials` → siblings (auto-suffix on filename collision) + * `topics` + `tags` merged (union) into frontmatter + * Materials footer regenerated to list every artifact in the folder + + First call (folder doesn't exist) → CREATE; subsequent calls with + the same `name` while the event is `status: active` → APPEND. + If the event is `status: distilled` / `archived`, `sync` REFUSES + and returns a `suggested_name` so you start a fresh thread + instead of mutating prior cognition. + + Zero LLM cost. Call CONTINUOUSLY through a task as facts land, + and especially at PreCompact to dump verbose raw text into + `materials` before context truncation loses it. The folder is + the unit `ingest` later reads from. + parameters: + type: object + required: [name] + properties: + name: + type: string + description: "kebab-case event identifier (folder + index stem). Reuse the same name across calls in one thread to keep extending the same folder." + description: { type: string, description: "one-line summary for index frontmatter (set on initial create only)" } + content: { type: string, description: "markdown body. Initial create: the body. Subsequent calls: appended as a `## Update — {iso}` section." } + topics: + type: array + items: { type: string } + description: "related topic wikilinks ('[[X]]' or '[[topics/X/X]]'). Unioned into frontmatter on append." + tags: { type: array, items: { type: string }, description: "free-form tags; unioned on append." } + materials: + type: array + description: "Raw artifacts written as siblings of the index inside the event folder. Filenames must be safe (letters/digits/dot/underscore/dash). On filename collision with an existing artifact, auto-suffixes (foo.txt → foo-2.txt)." + items: + type: object + required: [filename, content] + properties: + filename: { type: string, description: "e.g. 'raw-prompt.md', 'tool-output.txt', 'snapshot.json'" } + content: { type: string } + on_date: { type: string, description: "ISO date for events/{date}/ bucket; defaults to today" } + origin_session_id: { type: string, description: "set on initial create only" } + steps: + - backend: sync + + - backend: base + name: ingest + description: | + Cold-path LLM-driven distillation. Run on EXPLICIT HANDOFF only: + task completion / SessionEnd / when the agent decides the working + set is ready. Not a per-turn tool. + + The agent hands off the working set in two interchangeable forms + (use both as appropriate): + * `content` — inline material to distill: a hint / summary, or + raw text the agent is feeding directly. + * `related_paths` — paths the agent points at: event folder + indexes (the Ingestor will follow `## Materials` and read + each artifact), individual material files, or candidate + topics flagged for update. + + The Ingestor reads the working set + linked topics, decides which + existing topics to update / create, and flips each distilled + event's status to "distilled". + + NOT for raw event logging — use `sync` for that. + parameters: + type: object + required: [content] + properties: + content: + type: string + description: "Inline material the agent is feeding the Ingestor: distillation hint, session summary, or raw text. Combined with `related_paths` to form the working set." + hint: { type: string, description: "Caller guidance about target / intent." } + target_path: { type: string, description: "Optional suggested path; required for the no-LLM degraded path." } + metadata: { type: object, description: "Suggested frontmatter for any new topic." } + related_paths: + type: array + items: { type: string } + description: "Pointers the agent is feeding the Ingestor: event folder indexes (Ingestor will read materials from `## Materials`), individual material files, or candidate topics." + steps: + - backend: ingestor + + - backend: base + name: topic_create + description: | + Create a typed topic at `topics/{folder}/{name}.md` with hard + schema validation (judgment categories require `confidence`) and + wikilink-uniqueness enforcement. + parameters: + type: object + required: [folder, name, category] + properties: + folder: { type: string } + name: { type: string } + category: + type: string + enum: [company, sector, concept, method, tool, profile, thesis, model, questions, fundamentals] + description: { type: string } + content: { type: string } + confidence: { type: string, enum: ["⏳", "✅", "❌"] } + market: { type: string } + ticker: { type: string } + tags: { type: array, items: { type: string } } + steps: + - backend: topic_create + + # -- Read tools -------------------------------------------------------- + + - backend: base + name: memory_search + description: "Hybrid (vector + keyword) search over chunks." + parameters: + type: object + required: [query] + properties: + query: { type: string } + max_results: { type: integer, default: 5 } + min_score: { type: number, default: 0.1 } + paths: { type: array, items: { type: string } } + tags: { type: array, items: { type: string } } + exclude_paths: { type: array, items: { type: string } } + steps: + - backend: memory_search + + - backend: base + name: memory_graph_search + description: | + Three-way fusion search: vector + keyword + graph (BFS over wikilinks). + Pulls in chunks reachable through linked topics/events that pure + relevance search would miss. + parameters: + type: object + properties: + query: { type: string } + max_results: { type: integer, default: 5 } + min_score: { type: number, default: 0.0 } + graph_depth: { type: integer, default: 1 } + seeds: { type: array, items: { type: string } } + paths: { type: array, items: { type: string } } + tags: { type: array, items: { type: string } } + exclude_paths: { type: array, items: { type: string } } + steps: + - backend: memory_graph_search + + - backend: base + name: memory_get + description: "Read frontmatter + body of a single file." + parameters: + type: object + required: [path] + properties: + path: { type: string } + include_chunks: { type: boolean, default: false } + steps: + - backend: memory_get + + - backend: base + name: memory_list + description: | + List indexed files filtered by frontmatter exact-match, tags, or + path prefix. Returns {items: [{path, metadata}], count}. + parameters: + type: object + properties: + metadata: { type: object } + tags: { type: array, items: { type: string } } + path_prefix: { type: string } + limit: { type: integer, default: 100 } + steps: + - backend: memory_list + + - backend: base + name: memory_backlinks + description: "Files linking TO the given path (with edge predicates)." + parameters: + type: object + required: [path] + properties: + path: { type: string } + steps: + - backend: memory_backlinks + + - backend: base + name: memory_links + description: "Files the given path links to (resolved, with edge predicates)." + parameters: + type: object + required: [path] + properties: + path: { type: string } + steps: + - backend: memory_links + + - backend: base + name: memory_resolve_wikilink + description: | + Resolve a `[[target]]` wikilink to a vault path. Stem-form (`X`) + consults the file_store's stem index; path-form (`a/b` or `a/b.md`) + is anchored at the vault root. + parameters: + type: object + required: [wikilink] + properties: + wikilink: { type: string } + steps: + - backend: memory_resolve_wikilink + + - backend: base + name: memory_count_tokens + description: "Estimate token count for a file body or raw text." + parameters: + type: object + properties: + path: { type: string } + text: { type: string } + steps: + - backend: memory_count_tokens + + # -- Write primitives (raw building blocks) ---------------------------- + + - backend: base + name: memory_create + description: | + Create a new file (raw primitive — no LLM reasoning). Prefer + `ingest` when you want an LLM curator. Wikilink-uniqueness gate + runs unless `force=true`. + parameters: + type: object + required: [path] + properties: + path: { type: string } + metadata: { type: object } + content: { type: string } + overwrite: { type: boolean, default: false } + force: { type: boolean, default: false } + steps: + - backend: memory_create + + - backend: base + name: memory_update + description: | + Edit-style content update: replace `old_string` with `new_string` + in the file body. For frontmatter changes use `memory_property_update`. + parameters: + type: object + required: [path, old_string, new_string] + properties: + path: { type: string } + old_string: { type: string } + new_string: { type: string } + replace_all: { type: boolean, default: false } + steps: + - backend: memory_update + + - backend: base + name: memory_property_update + description: "Update a single YAML frontmatter key. value=null deletes the key." + parameters: + type: object + required: [path, key] + properties: + path: { type: string } + key: { type: string } + value: {} + steps: + - backend: memory_property_update + + - backend: base + name: memory_rename + description: | + Rename a file and rewrite all incoming `[[wikilink]]` references + across the vault. Refuses on destination conflict or stem ambiguity. + parameters: + type: object + required: [old_path, new_path] + properties: + old_path: { type: string } + new_path: { type: string } + steps: + - backend: memory_rename + + - backend: base + name: memory_delete + description: "Delete a file." + parameters: + type: object + required: [path] + properties: + path: { type: string } + steps: + - backend: memory_delete + + - backend: base + name: memory_archive + description: | + Archive a file: flip `status: archived` then move under + `//`. + parameters: + type: object + required: [path] + properties: + path: { type: string } + archive_dir: { type: string, default: "Archive" } + steps: + - backend: memory_archive + +components: + # Ingestor LLM (opt-in). Without this the Ingestor degrades to a + # direct create from explicit `target_path`; edits/renames/deletes + # require the LLM. Uncomment + provide LLM_API_KEY to enable. + # + # as_llm: + # default: + # backend: openai + # model_name: ${LLM_MODEL_NAME:-gpt-4o-mini} + # api_key: ${LLM_API_KEY} + # client_kwargs: + # base_url: ${LLM_BASE_URL:-https://api.openai.com/v1} + # stream: false + # + # as_llm_formatter: + # default: + # backend: openai + + as_token_counter: + default: + backend: estimated + + # Embedding is opt-in: leave embedding_model="" on file_store to run + # keyword-only; uncomment + flip to "default" to enable hybrid search. + # + # embedding_model: + # default: + # backend: openai + # model_name: ${EMBEDDING_MODEL_NAME:-text-embedding-3-small} + # dimensions: 1536 + # pass_dimensions: false + # enable_cache: true + # max_batch_size: 10 + # max_cache_size: 2000 + # max_input_length: 8192 + + edge_extractor: + default: + backend: regex + + file_parser: + md: + backend: md + edge_extractor: default + default: + backend: text + + file_store: + default: + backend: local + embedding_model: "" + store_name: "reme" + db_path: "./vault/.reme" + fts_enabled: true + + file_watcher: + default: + backend: full + file_store: default + default_parser: md + watch_path: "./vault" + recursive: true + + # Retriever (`hybrid`) is a Step, not a pre-instantiated component — + # the `memory_search` / `memory_graph_search` shells build it on + # demand. To tune defaults, pass knobs (`vector_weight`, `graph_*`, + # …) on the step config under each job below. diff --git a/reme2/config/paw.yaml b/reme2/config/paw.yaml index 999ac9ed..efc994f9 100644 --- a/reme2/config/paw.yaml +++ b/reme2/config/paw.yaml @@ -42,13 +42,28 @@ components: max_cache_size: 2000 max_input_length: 8192 + edge_extractor: + default: + backend: regex + llm: + backend: llm + as_llm: default + as_llm_formatter: default + file_store: default + max_input_chars: 8000 + min_confidence: 0.5 + max_iters: 8 + file_parser: md: backend: md + edge_extractor: default + embedding_model: default default: - backend: default + backend: text + embedding_model: default - chunk_store: + file_store: default: backend: local embedding_model: default @@ -58,7 +73,7 @@ components: file_watcher: default: backend: full - chunk_store: default + file_store: default default_parser: default watch_paths: [ "./test_data" ] recursive: true diff --git a/reme2/config/smoke_test.py b/reme2/config/smoke_test.py new file mode 100644 index 00000000..b874f2c2 --- /dev/null +++ b/reme2/config/smoke_test.py @@ -0,0 +1,308 @@ +"""Smoke-test reme2/config/full.yaml + reme2/config/curated.yaml. + +Boots Application with each profile against a temporary vault, lists +the registered jobs, then calls a representative subset to confirm +end-to-end wiring (parser → file_store → memory_io / ingest / +memory_graph_search). The Ingestor degrades gracefully when no LLM is +configured — we exercise the degraded path so the test is hermetic. + +Run: + python reme2/config/smoke_test.py +""" + +import asyncio +import json +import sys +import tempfile +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +# Eager-import side-effect modules so all @R.register() decorators run. +import reme2 # noqa: E402,F401 +import reme2.mcp.steps.memory_io # noqa: E402,F401 +import reme2.mcp.steps.memory_retriever # noqa: E402,F401 +import reme2.memory.ingestor # noqa: E402,F401 +import reme2.memory.summarizer # noqa: E402,F401 +import reme2.memory.maintainer # noqa: E402,F401 +import reme2.mcp.steps # noqa: E402,F401 + +from reme2.application import Application # noqa: E402 +from reme2.config import parse_args # noqa: E402 + +CONFIG_DIR = Path(__file__).resolve().parent +PROFILES = { + "full": CONFIG_DIR / "full.yaml", + "curated": CONFIG_DIR / "curated.yaml", +} + + +def _seed_vault(vault: Path) -> None: + """Drop a few markdown files so reads return something.""" + (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: + last = -1 + stable_for = 0 + for _ in range(int(timeout_s / 0.25)): + now = len(watcher.file_store) + if now == last and now >= expected_min: + stable_for += 1 + if stable_for >= 4: + return + else: + stable_for = 0 + last = now + await asyncio.sleep(0.25) + + +def _decode(resp) -> object: + """Job answers are JSON strings; decode for inspection. Pass through dicts/lists.""" + 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 _run_profile(name: str, config_path: Path) -> dict: + """Boot the profile against a fresh temp vault; exercise jobs; return summary.""" + print(f"\n========== profile: {name} ==========") + + with tempfile.TemporaryDirectory() as tmp: + vault = Path(tmp) / "vault" + vault.mkdir() + _seed_vault(vault) + + # Override watch_path / db_path / sidecar info; force HTTP service so + # we never block on stdio MCP (we only call jobs directly). + _, cfg = parse_args( + "start", + f"config={config_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() + try: + jobs = sorted(app.context.jobs.keys()) + print(f" registered jobs ({len(jobs)}): {jobs}") + + watcher = app.context.components["file_watcher"]["default"] + await _wait_for_index(watcher, expected_min=3) + print(f" file_store nodes after sync: {len(watcher.file_store)}") + assert len(watcher.file_store) >= 3, "watcher did not index seed files" + + results: dict = {"jobs": jobs, "checks": []} + alice_path = str((vault / "topics" / "Alice" / "Alice.md").resolve()) + + if "memory_get" in jobs: + r = _decode(await app.run_job("memory_get", path=alice_path)) + ok = isinstance(r, dict) and r.get("exists") is True + print(f" memory_get(Alice.md) → exists={ok}, edges={len(r.get('link', []))}") + results["checks"].append(("memory_get", ok)) + + if "memory_list" in jobs: + r = _decode(await app.run_job("memory_list", tags=["person"])) + ok = isinstance(r, dict) and r.get("count", 0) >= 2 + print(f" memory_list(tags=[person]) → count={r.get('count') if isinstance(r, dict) else '?'}") + results["checks"].append(("memory_list", ok)) + + if "memory_search" in jobs: + r = _decode(await app.run_job("memory_search", query="collaborates", max_results=3, min_score=0.0)) + hits = r if isinstance(r, list) else (r.get("chunks") if isinstance(r, dict) else []) + ok = len(hits) > 0 + print(f" memory_search('collaborates') → {len(hits)} hits") + results["checks"].append(("memory_search", ok)) + + if "memory_links" in jobs: + r = _decode(await app.run_job("memory_links", path=alice_path)) + ok = isinstance(r, dict) and len(r.get("links", [])) >= 2 + print(f" memory_links(Alice.md) → {len(r.get('links', []))} resolved") + results["checks"].append(("memory_links", ok)) + + if "query" in jobs: + r = _decode(await app.run_job("query", query="Alice Bob", max_results=3, min_score=0.0)) + hits = r if isinstance(r, list) else (r.get("chunks") if isinstance(r, dict) else []) + ok = len(hits) > 0 + print(f" query('Alice Bob') → {len(hits)} hits") + results["checks"].append(("query", ok)) + + if "ingest" in jobs: + # Degraded path: no LLM key set in env → Ingestor falls back + # to direct create from `target_path`. That's the hermetic path. + target = str((vault / "topics" / "smoke" / "smoke.md").resolve()) + r = _decode(await app.run_job( + "ingest", + content="# smoke topic\n\nrecorded by config smoke test.\n", + target_path=target, + metadata={"category": "concept", "title": "smoke"}, + )) + ok = isinstance(r, dict) and (r.get("applied") or r.get("skipped")) + print(f" ingest(degraded create) → applied={len(r.get('applied', [])) if isinstance(r, dict) else '?'}, " + f"used_llm={r.get('used_llm') if isinstance(r, dict) else '?'}") + results["checks"].append(("ingest", bool(ok))) + # Confirm the file landed on disk. + assert Path(target).is_file(), "ingest did not produce the target file" + + if "sync" in jobs: + # CREATE call. + r = _decode(await app.run_job( + "sync", + name="smoke-event", + description="smoke test event", + content="## ops\n- ran the smoke test\n", + topics=["[[Alice]]"], + tags=["smoke"], + materials=[ + {"filename": "raw-prompt.md", "content": "# raw user prompt\n\nrun the smoke test\n"}, + {"filename": "tool-output.txt", "content": "tool ran ok\nexit=0\n"}, + ], + )) + ok = ( + isinstance(r, dict) + and r.get("created") is True + and r.get("action") == "created" + and len(r.get("materials", [])) == 2 + ) + materials = r.get("materials", []) if isinstance(r, dict) else [] + print(f" sync(create smoke-event w/ 2 materials) → created={r.get('created') if isinstance(r, dict) else '?'}, " + f"materials={len(materials)}") + results["checks"].append(("sync.create", bool(ok))) + for m in materials: + assert Path(m).is_file(), f"event material missing: {m}" + if materials: + event_dir = Path(materials[0]).parent + index_text = (event_dir / "smoke-event.md").read_text(encoding="utf-8") + assert "## Materials" in index_text, "index .md missing Materials section" + assert "raw-prompt.md" in index_text, "Materials section missing raw-prompt link" + await _wait_for_index(watcher, expected_min=len(watcher.file_store) + 2) + + # APPEND call: same name, new content + new + colliding material. + r2 = _decode(await app.run_job( + "sync", + name="smoke-event", + content="## follow-up\n- second pass facts\n", + topics=["[[Bob]]"], # union with prior [[Alice]] + tags=["follow-up"], # union with prior [smoke] + materials=[ + {"filename": "tool-output.txt", "content": "second tool run\nexit=0\n"}, # collision → auto-suffix + {"filename": "summary.md", "content": "# summary\nsecond pass\n"}, + ], + )) + ok2 = ( + isinstance(r2, dict) + and r2.get("created") is False + and r2.get("action") == "appended" + and len(r2.get("materials", [])) == 2 + ) + appended_paths = r2.get("materials", []) if isinstance(r2, dict) else [] + print(f" sync(append smoke-event w/ collision) → action={r2.get('action') if isinstance(r2, dict) else '?'}, " + f"new_materials={len(appended_paths)}") + results["checks"].append(("sync.append", bool(ok2))) + # Collision should have produced tool-output-2.txt; summary.md untouched. + names_appended = {Path(p).name for p in appended_paths} + assert "tool-output-2.txt" in names_appended, f"collision auto-suffix missing: {names_appended}" + assert "summary.md" in names_appended, f"clean filename missing: {names_appended}" + # Index should now contain BOTH the original ops section and the Update section. + if materials: + index_text2 = (Path(materials[0]).parent / "smoke-event.md").read_text(encoding="utf-8") + assert "## ops" in index_text2, "original content lost on append" + assert "## Update —" in index_text2, "missing Update section after append" + assert "follow-up" in index_text2, "appended content not in index" + # Frontmatter union check + assert "[[Bob]]" in index_text2, "topic union failed" + # Materials footer should now list 4 files (2 original + summary + tool-output-2) + assert "tool-output-2.txt" in index_text2, "Materials footer missing collided file" + assert "summary.md" in index_text2, "Materials footer missing new file" + + # REFUSAL: flip status to distilled, third sync should refuse. + index_path = str(Path(materials[0]).parent / "smoke-event.md") if materials else None + if index_path and "memory_property_update" in jobs: + await app.run_job("memory_property_update", path=index_path, key="status", value="distilled") + r3 = _decode(await app.run_job( + "sync", + name="smoke-event", + content="should refuse", + )) + ok3 = isinstance(r3, dict) and "error" in r3 and r3.get("status") == "distilled" + print(f" sync(refuse on distilled) → error={'error' in (r3 if isinstance(r3, dict) else {})}, " + f"suggested_name={r3.get('suggested_name') if isinstance(r3, dict) else '?'}") + results["checks"].append(("sync.refuse_distilled", bool(ok3))) + + if "topic_create" in jobs: + r = _decode(await app.run_job( + "topic_create", + folder="Carol", + name="Carol", + category="profile", + description="smoke test", + content="# Carol\n", + tags=["person"], + )) + ok = isinstance(r, dict) and (r.get("created") is True or "path" in r) + print(f" topic_create(Carol) → created={r.get('created') if isinstance(r, dict) else '?'}") + results["checks"].append(("topic_create", bool(ok))) + + return results + finally: + await app.close() + + +async def _main() -> int: + summary: dict[str, dict] = {} + for name, path in PROFILES.items(): + try: + summary[name] = await _run_profile(name, path) + except Exception as e: + print(f" ✗ profile '{name}' failed: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + summary[name] = {"error": str(e)} + + print("\n========== summary ==========") + failed = 0 + for name, result in summary.items(): + if "error" in result: + print(f" {name}: ERROR — {result['error']}") + failed += 1 + continue + checks = result.get("checks", []) + passed = sum(1 for _, ok in checks if ok) + total = len(checks) + marker = "✓" if passed == total else "✗" + print(f" {marker} {name}: {passed}/{total} checks passed; jobs={len(result['jobs'])}") + for label, ok in checks: + if not ok: + print(f" ✗ {label}") + failed += 1 + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(_main())) diff --git a/reme2/enumeration/component_enum.py b/reme2/enumeration/component_enum.py index 7f74a167..942c7696 100644 --- a/reme2/enumeration/component_enum.py +++ b/reme2/enumeration/component_enum.py @@ -24,9 +24,11 @@ class ComponentEnum(str, Enum): EMBEDDING_MODEL = "embedding_model" + EDGE_EXTRACTOR = "edge_extractor" + FILE_PARSER = "file_parser" - CHUNK_STORE = "chunk_store" + FILE_STORE = "file_store" FILE_WATCHER = "file_watcher" diff --git a/reme2/file_based/__init__.py b/reme2/file_based/__init__.py deleted file mode 100644 index 81b58f52..00000000 --- a/reme2/file_based/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""File-based components and utilities.""" - -from .file_io import FileIO -from .file_utils import ( - async_read_file_safe, - truncate_text_output, -) -from .memory_search import MemorySearch -from .summarizer import Summarizer - -__all__ = [ - "FileIO", - "async_read_file_safe", - "truncate_text_output", - "MemorySearch", - "Summarizer", -] diff --git a/reme2/file_based/file_io.py b/reme2/file_based/file_io.py deleted file mode 100644 index 22399015..00000000 --- a/reme2/file_based/file_io.py +++ /dev/null @@ -1,361 +0,0 @@ -"""File I/O operations with a configurable working directory.""" - -import os -from pathlib import Path - -import aiofiles -from agentscope.message import TextBlock -from agentscope.tool import ToolResponse - -from .file_utils import async_read_file_safe, truncate_text_output -from ..constants import TRUNCATION_NOTICE_MARKER - - -class FileIO: - """File I/O operations with a configurable working directory.""" - - def __init__(self, working_dir: str | Path): - """Initialize FileIO with a working directory. - - Args: - working_dir (`str`): - The working directory for resolving relative paths. - """ - self.working_dir = Path(working_dir) - - def _resolve_file_path(self, file_path: str) -> str: - """Resolve file path: use absolute path as-is, - resolve relative path from working_dir. - - Args: - file_path: The input file path (absolute or relative). - - Returns: - The resolved absolute file path as string. - """ - path = Path(file_path).expanduser() - if path.is_absolute(): - return str(path) - else: - return str(self.working_dir / file_path) - - async def read_file( # pylint: disable=too-many-return-statements - self, - file_path: str, - start_line: int | None = None, - end_line: int | None = None, - ) -> ToolResponse: - """Read a file. Relative paths resolve from WORKING_DIR. - - Use start_line/end_line to read a specific line range (output includes - line numbers). Omit both to read the full file. - - Args: - file_path (`str`): - Path to the file. - start_line (`int`, optional): - First line to read (1-based, inclusive). - end_line (`int`, optional): - Last line to read (1-based, inclusive). - """ - - # Convert start_line/end_line to int if they are strings - if start_line is not None: - try: - start_line = int(start_line) - except (ValueError, TypeError): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: start_line must be an integer, got {start_line!r}.", - ), - ], - ) - - if end_line is not None: - try: - end_line = int(end_line) - except (ValueError, TypeError): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: end_line must be an integer, got {end_line!r}.", - ), - ], - ) - - file_path = self._resolve_file_path(file_path) - - if not os.path.exists(file_path): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: The file {file_path} does not exist.", - ), - ], - ) - - if not os.path.isfile(file_path): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: The path {file_path} is not a file.", - ), - ], - ) - - try: - content = await async_read_file_safe(file_path) - all_lines = content.split("\n") - total = len(all_lines) - - # Determine read range - s = max(1, start_line if start_line is not None else 1) - e = min(total, end_line if end_line is not None else total) - - if s > total: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: start_line {s} exceeds file length ({total} lines).", - ), - ], - ) - - if s > e: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: start_line ({s}) > end_line ({e}).", - ), - ], - ) - - # Extract selected lines - selected_content = "\n".join(all_lines[s - 1: e]) - - # Apply smart truncation (consistent with shell output format) - text = truncate_text_output( - selected_content, - start_line=s, - total_lines=total, - file_path=file_path, - ) - - # Add continuation hint if partial read without truncation. - # Use TRUNCATION_NOTICE_MARKER format so ToolResultCompactor can - # re-truncate with the correct start_line when compacting old messages. - if text == selected_content and e < total: - content_bytes = len(text.encode("utf-8")) - notice = ( - TRUNCATION_NOTICE_MARKER + f"\nThe output above was truncated." - f"\nThe full content is saved to the file " - f"and contains {total} lines in total." - f"\nThis excerpt starts at line {s} and " - f"covers the next {content_bytes} bytes." - "\nIf the current content is not enough, " - f"call `read_file` with file_path={file_path} start_line={e + 1} to read more." - ) - text = text + notice - - return ToolResponse( - content=[TextBlock(type="text", text=text)], - ) - - except Exception as e: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: Read file failed due to \n{e}", - ), - ], - ) - - async def write_file( - self, - file_path: str, - content: str, - ) -> ToolResponse: - """Create or overwrite a file. Relative paths resolve from working_dir. - - Args: - file_path (`str`): - Path to the file. - content (`str`): - Content to write. - """ - if not file_path: - return ToolResponse( - content=[ - TextBlock( - type="text", - text="Error: No `file_path` provided.", - ), - ], - ) - - file_path = self._resolve_file_path(file_path) - - try: - async with aiofiles.open(file_path, "w", encoding="utf-8") as file: - await file.write(content) - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Wrote {len(content)} bytes to {file_path}.", - ), - ], - ) - except Exception as e: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: Write file failed due to \n{e}", - ), - ], - ) - - # pylint: disable=too-many-return-statements - async def edit_file( - self, - file_path: str, - old_text: str, - new_text: str, - ) -> ToolResponse: - """Find-and-replace text in a file. All occurrences of old_text are - replaced with new_text. Relative paths resolve from working_dir. - - Args: - file_path (`str`): - Path to the file. - old_text (`str`): - Exact text to find. - new_text (`str`): - Replacement text. - """ - if not file_path: - return ToolResponse( - content=[ - TextBlock( - type="text", - text="Error: No `file_path` provided.", - ), - ], - ) - - resolved_path = self._resolve_file_path(file_path) - - if not os.path.exists(resolved_path): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: The file {resolved_path} does not exist.", - ), - ], - ) - - if not os.path.isfile(resolved_path): - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: The path {resolved_path} is not a file.", - ), - ], - ) - - try: - content = await async_read_file_safe(resolved_path) - except Exception as e: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: Read file failed due to \n{e}", - ), - ], - ) - - if old_text not in content: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: The text to replace was not found in {file_path}.", - ), - ], - ) - - new_content = content.replace(old_text, new_text) - write_response = await self.write_file(file_path=resolved_path, content=new_content) - - if write_response.content and len(write_response.content) > 0: - write_text = write_response.content[0].get("text", "") - if write_text.startswith("Error:"): - return write_response - - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Successfully replaced text in {file_path}.", - ), - ], - ) - - async def append_file( - self, - file_path: str, - content: str, - ) -> ToolResponse: - """Append content to the end of a file. Relative paths resolve from - working_dir. - - Args: - file_path (`str`): - Path to the file. - content (`str`): - Content to append. - """ - if not file_path: - return ToolResponse( - content=[ - TextBlock( - type="text", - text="Error: No `file_path` provided.", - ), - ], - ) - - file_path = self._resolve_file_path(file_path) - - try: - async with aiofiles.open(file_path, "a", encoding="utf-8") as file: - await file.write(content) - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Appended {len(content)} bytes to {file_path}.", - ), - ], - ) - except Exception as e: - return ToolResponse( - content=[ - TextBlock( - type="text", - text=f"Error: Append file failed due to \n{e}", - ), - ], - ) diff --git a/reme2/file_based/file_utils.py b/reme2/file_based/file_utils.py deleted file mode 100644 index e0875679..00000000 --- a/reme2/file_based/file_utils.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Shared utilities for file and shell tools.""" - -import re - -import aiofiles - -from ..constants import ( - DEFAULT_MAX_BYTES, - MAX_FILE_READ_BYTES, - TRUNCATION_NOTICE_MARKER, -) - - -def _truncate_fresh( - text: str, - start_line: int, - total_lines: int, - max_bytes: int, - file_path: str | None, - encoding: str, -) -> str: - """Truncate fresh text (no prior truncation marker) by bytes with line integrity. - - Slices at the byte boundary and appends a truncation notice with a continuation - hint so callers know which line to read next. - - Returns the original text unchanged when it fits within max_bytes, or when the - last line itself exceeds max_bytes (unhandled edge case). - """ - text_bytes = text.encode(encoding) - - # Under the byte limit — return as-is without any modification. - if len(text_bytes) <= max_bytes: - return text - - # Slice at the byte boundary. - # Assuming every single line is shorter than DEFAULT_MAX_BYTES, this cut always - # lands mid-line, guaranteeing at least one complete line before the boundary. - # Lines that exceed DEFAULT_MAX_BYTES are not handled and may be skipped entirely. - truncated = text_bytes[:max_bytes] - # Decode back to str; errors="ignore" drops any split multibyte character - # at the cut boundary without raising an exception. - result = truncated.decode(encoding, errors="ignore") - - # Count '\n' characters to determine how many complete lines are included. - # The tail after the final '\n' is a partial line that will be covered by - # the next read starting at next_line. - newline_count = result.count("\n") - - # Compute the first line number not yet fully included in this chunk. - # max(1, ...) prevents next_line from equaling start_line when a single line - # exceeds max_bytes (newline_count == 0), which would make the caller retry - # the same range indefinitely. - next_line = start_line + max(1, newline_count) - - if next_line <= total_lines: - # Truncation fell before the last line — continue reading from next_line. - read_from = next_line - elif start_line < total_lines: - # next_line overshot total_lines, meaning the cut landed inside the last line. - # Re-read from the start of the last line so the caller gets it in full. - read_from = total_lines - else: - # start_line == total_lines: the last line itself exceeds DEFAULT_MAX_BYTES. - # This case is outside our handled range — return without a truncation notice. - 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_file` with file_path={file_path or ''} " - f"start_line={read_from} to read more." - ) - - return result + notice - - -def _retruncate( - text: str, - max_bytes: int, - encoding: str, -) -> str: - """Re-truncate text that was previously truncated (contains TRUNCATION_NOTICE_MARKER). - - Extracts the original content before the marker, applies the new byte limit, and - updates the embedded notice (byte count and continuation line number) via regex. - - Returns the original text unchanged when: - - the content already fits within max_bytes (with a small slack); - - required metadata fields cannot be parsed from the existing notice. - """ - parts = text.split(TRUNCATION_NOTICE_MARKER, 1) - original_content = parts[0] - old_notice = parts[1] - - text_bytes = original_content.encode(encoding) - - # Allow a small slack to avoid unnecessary re-truncation when content is just - # barely over the limit (e.g. due to minor encoding differences). - if len(text_bytes) <= max_bytes + 100: - return text - - # Parse start_line from notice; return text unchanged if not found - start_match = re.search(r"starts at line (\d+)", old_notice) - if not start_match: - return text - start_line_parsed = int(start_match.group(1)) - - # Re-slice to the new byte limit. - # Because every line is assumed to be shorter than DEFAULT_MAX_BYTES, the cut - # always falls somewhere mid-line, so at least one complete line is preserved. - truncated_bytes = text_bytes[:max_bytes] - # errors="ignore" silently drops any incomplete multibyte character at the cut boundary. - result = truncated_bytes.decode(encoding, errors="ignore") - # Each '\n' in result corresponds to one fully-included line; - # anything after the last '\n' is a partial line that was cut off. - newline_count = result.count("\n") - - # The next read should start at the line immediately after all complete lines. - # max(1, ...) guards against the theoretical zero-newline case - # (impossible when every line is shorter than DEFAULT_MAX_BYTES). - next_line = start_line_parsed + max(1, newline_count) - - if not re.search(r"covers the next \d+ bytes", old_notice): - return text - # _truncate_fresh always includes a continuation hint, so both fields are always present. - new_notice = re.sub(r"covers the next \d+ bytes", f"covers the next {max_bytes} bytes", old_notice) - new_notice = re.sub(r"start_line=\d+ to read more", f"start_line={next_line} to read more", new_notice) - - return result + TRUNCATION_NOTICE_MARKER + new_notice - - -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 file output by bytes with line integrity. - - If text is under byte limit, return as-is. - If over limit, truncate at the last complete line that fits, - allowing the next read to start from a fresh line. - - Dispatches to :func:`_truncate_fresh` for text seen for the first time, or to - :func:`_retruncate` when the text already contains a TRUNCATION_NOTICE_MARKER - from a previous pass. - - Args: - text: The output text to truncate. - start_line: The starting line number (1-based). Ignored when text already - contains a truncation notice (values are parsed from the notice instead). - total_lines: Total lines in the original file. Ignored when text already - contains a truncation notice (values are parsed from the notice instead). - max_bytes: Maximum size in bytes. - file_path: Optional file path to include in the truncation notice. - encoding: Character encoding used for byte-length calculation and decoding. - - Returns: - Truncated text with notice if truncated. - """ - if not text: - return text - if max_bytes <= 0: - return text - - try: - if TRUNCATION_NOTICE_MARKER in text: - return _retruncate(text, max_bytes=max_bytes, encoding=encoding) - else: - return _truncate_fresh( - text, - start_line=start_line, - total_lines=total_lines, - max_bytes=max_bytes, - file_path=file_path, - encoding=encoding, - ) - except Exception: - return text - - -async def async_read_file_safe(file_path: str, max_bytes: int = MAX_FILE_READ_BYTES) -> str: - """Async version of read_file_safe with Unicode error handling and memory protection. - - Args: - file_path: Path to the file. - max_bytes: Maximum bytes to read into memory (default 1GB). - - Returns: - File content as string (up to max_bytes). - """ - try: - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: - return await f.read(max_bytes) - except UnicodeDecodeError: - async with aiofiles.open(file_path, "r", encoding="utf-8", errors="ignore") as f: - return await f.read(max_bytes) diff --git a/reme2/file_based/memory_search.py b/reme2/file_based/memory_search.py deleted file mode 100644 index 4abb7507..00000000 --- a/reme2/file_based/memory_search.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Memory search step for semantic search in memory files.""" - -import json - -from ..component import R -from ..component.base_step import BaseStep - - -@R.register("memory_search") -class MemorySearch(BaseStep): - """Semantically search MEMORY.md and memory files.""" - - def __init__(self, vector_weight: float = 0.7, candidate_multiplier: float = 3.0, **kwargs): - """Initialize memory search step. - - Args: - vector_weight: Weight for vector search vs keyword search. - candidate_multiplier: Multiplier for candidate count before filtering. - **kwargs: Additional arguments passed to BaseStep. - """ - super().__init__(**kwargs) - self.vector_weight = vector_weight - self.candidate_multiplier = candidate_multiplier - - async def execute(self): - """Execute the memory search operation.""" - assert self.context is not None, "Context is not set" - query: str = self.context.get("query", "").strip() - min_score: float = self.context.get("min_score", 0.1) - max_results: int = self.context.get("max_results", 5) - - assert query, "Query cannot be empty" - assert ( - isinstance(min_score, float | int) and 0.0 <= min_score <= 1.0 - ), f"min_score must be between 0 and 1, got {min_score}" - assert ( - isinstance(max_results, int) and max_results > 0 - ), f"max_results must be a positive integer, got {max_results}" - - chunk_filter = self.file_graph.filter( - paths=self.context.get("paths") or None, - tags=self.context.get("tags") or None, - exclude_paths=self.context.get("exclude_paths") or None, - ) - - results = await self.chunk_store.hybrid_search( - query=query, - limit=max_results, - vector_weight=self.vector_weight, - candidate_multiplier=self.candidate_multiplier, - chunk_filter=chunk_filter, - ) - - results = [r for r in results if r.score >= min_score] - - return json.dumps([result.model_dump(exclude_none=True) for result in results], indent=2, ensure_ascii=False) diff --git a/reme2/mcp/README.md b/reme2/mcp/README.md new file mode 100644 index 00000000..7ae4b705 --- /dev/null +++ b/reme2/mcp/README.md @@ -0,0 +1,73 @@ +# reme2.mcp + +Agent-facing MCP interface layer. Exposes the markdown vault under +`reme2/` (file_store + watcher + memory services) as MCP tools for +`claude-code` and other MCP clients. + +## Layout + +``` +reme2/mcp/ +├── __init__.py +├── server.py MCP server bootstrap; defaults to ../config/full.yaml +└── steps/ @R.register MCP step shells + ├── memory_io.py memory_create/update/get/list/links/... + ├── memory_retriever.py memory_search + memory_graph_search + ├── sync.py hot-path event-folder upsert + └── topic_create.py typed topic creation w/ schema gates +``` + +The MCP layer's job is to **project** existing primitives as MCP tools +— it owns no business logic. Everything else lives outside the +transport boundary so memory services don't form an import cycle: + +| Concern | Location | +|---|---| +| Memory File System primitives | `reme2/component/file_store/`, `file_watcher/`, `file_parser/` | +| Three memory services | `reme2/memory/` — Retriever / Ingestor / Maintainer | +| Pure write helpers + agent toolkit | `reme2/memory/memory_io.py` | +| Vault domain models (Event, Topic) | `reme2/schema/vault/` | +| Path templates + name disambiguation | `reme2/utils/vault_paths.py` | +| Step response serialization | `reme2/component/runtime_response.py` | + +Dependency direction is strict: +``` +reme2.mcp → reme2.memory → reme2.component / reme2.schema / reme2.utils +``` + +## Run + +```bash +# stdio MCP server, full tool surface +python -m reme2.mcp.server + +# override config or any field +python -m reme2.mcp.server config=reme2/config/curated.yaml +python -m reme2.mcp.server components.file_watcher.default.watch_path=/abs/vault +``` + +Config profiles (in `reme2/config/`): +- `full.yaml` — every memory_* primitive + sync + topic_create + ingest +- `curated.yaml` — opinionated 3-tool surface (`query`, `sync`, `ingest`) + +## Tools exposed (full profile) + +| Tool | Path | Purpose | +|---|---|---| +| `sync` | steps/sync.py | Hot-path event-folder upsert (idempotent per `(date, name)`). | +| `ingest` | reme2/memory/ingestor.py | Cold-path LLM-driven distillation. | +| `topic_create` | steps/topic_create.py | Typed topic creation with schema gates. | +| `memory_search` / `memory_graph_search` | steps/memory_retriever.py | V+K hybrid + optional graph BFS. | +| `memory_get` / `memory_list` / `memory_links` / `memory_backlinks` / `memory_resolve_wikilink` | steps/memory_io.py | Read primitives. | +| `memory_create` / `memory_update` / `memory_property_update` / `memory_rename` / `memory_delete` / `memory_archive` | steps/memory_io.py | Raw write primitives (prefer `ingest` / `sync`). | +| `memory_count_tokens` | steps/memory_io.py | Token estimation. | + +## Smoke test + +```bash +python reme2/config/smoke_test.py +``` + +Boots both `full` and `curated` profiles in a temp vault, exercises a +representative subset of jobs end-to-end (in-process — no MCP +transport). diff --git a/reme2/mcp/__init__.py b/reme2/mcp/__init__.py new file mode 100644 index 00000000..2a107aae --- /dev/null +++ b/reme2/mcp/__init__.py @@ -0,0 +1,20 @@ +"""reme2.mcp — MCP interface layer. + +The Agent-facing surface: server entrypoint + step shells that wrap +the three services in `reme2.memory` (Retriever, Ingestor, Maintainer) +plus hot-write primitives (sync, topic_create, memory_*) that bypass +services and land directly on the Memory File System. + +This package depends on `reme2.memory`, `reme2.schema.vault`, +`reme2.utils`, `reme2.component` — never the reverse. Domain types +(Topic / Event), pure helpers (path builders, naming), and the write +primitives all live outside `mcp/` so memory-layer services can use +them without forming an import cycle through the transport layer. + +Sub-packages: + steps/ - all @R.register MCP step shells (memory_io, memory_retriever, + sync, topic_create). + server.py - MCP server bootstrap (defaults to ../config/full.yaml). +""" + +__version__ = "0.1.0" diff --git a/reme2/mcp/server.py b/reme2/mcp/server.py new file mode 100644 index 00000000..2a4f8c74 --- /dev/null +++ b/reme2/mcp/server.py @@ -0,0 +1,74 @@ +"""vault MCP server entry point. + +Usage: + python -m reme2.mcp.server [config=path/to/yaml] [service.transport=stdio] + python reme2/mcp/server.py [config=path/to/yaml] [service.transport=stdio] + +By default loads `reme2/config/full.yaml` and starts the +ReMe2 application with the MCP service registered. + +Env vars: + VAULT_PATH — override vault watch path (overrides config + cli args). +""" + +import os +import sys +from pathlib import Path + +# Make this work whether invoked as `python -m reme2.mcp.server` (repo +# root already on sys.path) or `python reme2/mcp/server.py` (only this +# file's dir is on sys.path — without this, `import reme2.mcp.steps` +# would fail with ModuleNotFoundError). +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from dotenv import load_dotenv # noqa: E402 + +# Load .env from repo root before anything else touches env vars +# (embedding/llm clients read them at construction time). +load_dotenv(_REPO_ROOT / ".env") + +# Eagerly 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 +import reme2.component.service.mcp_service # noqa: E402,F401 + +from reme2.application import Application # noqa: E402 +from reme2.config import parse_args # noqa: E402 + + +_DEFAULT_CONFIG = str(_REPO_ROOT / "reme2" / "config" / "full.yaml") + + +def main() -> None: + argv = list(sys.argv[1:]) + if not argv or argv[0].startswith("config=") or "=" in argv[0]: + argv.insert(0, "start") + if not any(a.startswith("config=") for a in argv[1:]): + argv.insert(1, f"config={_DEFAULT_CONFIG}") + + vault_path = os.environ.get("VAULT_PATH") + if vault_path: + argv.append(f"components.file_watcher.default.watch_path={vault_path}") + argv.append(f"components.file_store.default.db_path={vault_path}/.reme") + argv.append(f"service.sidecar_info_path={vault_path}/.reme/sidecar.json") + + sidecar_port = os.environ.get("VAULT_HTTP_PORT") + if sidecar_port: + argv.append(f"service.sidecar_http_port={sidecar_port}") + + action, config = parse_args(*argv) + if action != "start": + raise SystemExit("reme2.mcp.server only supports the 'start' action") + + app = Application(**config) + app.run_app() + + +if __name__ == "__main__": + main() diff --git a/reme2/mcp/steps/__init__.py b/reme2/mcp/steps/__init__.py new file mode 100644 index 00000000..1b7a0e70 --- /dev/null +++ b/reme2/mcp/steps/__init__.py @@ -0,0 +1,31 @@ +"""MCP step shells — the @R.register classes the model invokes as MCP tools. + +Two groups: + + Hot-write primitives that bypass services and land on MFS directly: + sync — idempotent event-folder upsert (event log) + topic_create — typed topic creation (hard schema gates) + memory_io — memory_create / update / property_update / rename / + delete / archive / get / list / links / backlinks / + resolve_wikilink / count_tokens + + Service delegate: + memory_retriever — memory_search + memory_graph_search; thin + wrappers that delegate to the configured + Retriever component (`reme2.memory.retriever`). + +The Ingestor's MCP face (`ingest`) is registered from +`reme2.memory.ingestor`, since it's a service step rather than an MFS +primitive. Importing this package triggers all the step registrations +hosted here. +""" + +from . import memory_io # noqa: F401 -- triggers @R.register for memory_* +from . import memory_retriever # noqa: F401 -- memory_search / memory_graph_search +from .sync import Sync +from .topic_create import TopicCreate + +__all__ = [ + "Sync", + "TopicCreate", +] diff --git a/reme2/mcp/steps/memory_io.py b/reme2/mcp/steps/memory_io.py new file mode 100644 index 00000000..037930fb --- /dev/null +++ b/reme2/mcp/steps/memory_io.py @@ -0,0 +1,208 @@ +"""MCP step shells over the Memory File System engine API. + +Per `structure.md`, .md files are the SSOT and the engine surface lives +in `reme2.memory.memory_io` (CRUD writes + MFS reads + Projections). +This module only hosts the `@R.register("memory_*")` Step shells — +each one translates RuntimeContext ↔ JSON payload and delegates to the +matching engine API function. + +Search ops live in `memory_retriever.py` (Retriever composes V/K/graph +projections with policy). +""" + +from __future__ import annotations + +from pathlib import Path + +from ...component import R +from ...component.base_step import BaseStep +from ...component.runtime_response import _set_answer +from ...enumeration import ComponentEnum +from ...memory import memory_io + + +@R.register("memory_get") +class MemoryGet(BaseStep): + """Read a single memory file (frontmatter + body, optional chunks).""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + include_chunks: bool = bool(self.context.get("include_chunks", False)) + assert path, "path is required" + result = await memory_io.read_file(self.file_store, path, include_chunks=include_chunks) + _set_answer(self.context, result) + + +@R.register("memory_list") +class MemoryList(BaseStep): + """List indexed files filtered by frontmatter fields, tags, or path prefix.""" + + async def execute(self): + assert self.context is not None + result = memory_io.list_files( + self.file_store, + path_prefix=self.context.get("path_prefix"), + tags=self.context.get("tags") or [], + metadata=self.context.get("metadata") or {}, + limit=int(self.context.get("limit", 100)), + ) + _set_answer(self.context, result) + + +@R.register("memory_backlinks") +class MemoryBacklinks(BaseStep): + """Files linking to a given path. Each entry carries the typed-edge predicate.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + assert path, "path is required" + _set_answer(self.context, memory_io.backlinks_of(self.file_store, path)) + + +@R.register("memory_links") +class MemoryLinks(BaseStep): + """Files a given path links to (resolved). Each entry carries the typed-edge predicate.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + assert path, "path is required" + _set_answer(self.context, memory_io.links_of(self.file_store, path)) + + +@R.register("memory_resolve_wikilink") +class MemoryResolveWikilink(BaseStep): + """Resolve a `[[target]]` wikilink with full ambiguity context.""" + + async def execute(self): + assert self.context is not None + wikilink: str = self.context.get("wikilink", "") or "" + assert wikilink, "wikilink is required" + payload = memory_io.wikilink_lookup(self.file_store, wikilink) + self.context.response.success = bool(payload.get("exists")) + _set_answer(self.context, payload) + + +@R.register("memory_create") +class MemoryCreate(BaseStep): + """Create a markdown file. Wikilink-uniqueness gate runs unless force=True.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + metadata: dict = dict(self.context.get("metadata", {}) or {}) + content: str = self.context.get("content", "") or "" + overwrite: bool = bool(self.context.get("overwrite", False)) + force: bool = bool(self.context.get("force", False)) + + assert path, "path is required" + target = Path(path) + + ok, payload = memory_io.write_create( + self.file_store, target, + metadata=metadata, content=content, + overwrite=overwrite, force=force, + ) + self.context.response.success = ok + if ok: + payload = {**payload, "path": str(target.resolve())} + _set_answer(self.context, payload) + + +@R.register("memory_delete") +class MemoryDelete(BaseStep): + """Delete a file. Watcher removes from store + graph.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + assert path, "path is required" + ok, payload = memory_io.write_delete(path) + self.context.response.success = ok + _set_answer(self.context, payload) + + +@R.register("memory_rename") +class MemoryRename(BaseStep): + """Rename a file and rewrite incoming wikilinks across the vault.""" + + async def execute(self): + assert self.context is not None + old_path: str = self.context.get("old_path", "") + new_path: str = self.context.get("new_path", "") + assert old_path and new_path, "old_path and new_path are required" + + watcher = self.app_context.components["file_watcher"]["default"] # type: ignore[index,union-attr] + vault_root = Path(watcher.watch_path).resolve() # type: ignore[union-attr] + + ok, payload = memory_io.write_rename(self.file_store, vault_root, old_path, new_path) + self.context.response.success = ok + _set_answer(self.context, payload) + + +@R.register("memory_property_update") +class MemoryPropertyUpdate(BaseStep): + """Update a single YAML frontmatter key. value=null deletes the key.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + key: str = self.context.get("key", "") + value = self.context.get("value") + assert path and key, "path and key are required" + ok, payload = memory_io.write_property_update(path, key, value) + self.context.response.success = ok + _set_answer(self.context, payload) + + +@R.register("memory_update") +class MemoryUpdate(BaseStep): + """Edit-style content update: replace `old_string` with `new_string`.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") + old_string: str = self.context.get("old_string", "") + new_string: str = self.context.get("new_string", "") + replace_all: bool = bool(self.context.get("replace_all", False)) + assert path, "path is required" + ok, payload = memory_io.write_update(path, old_string, new_string, replace_all=replace_all) + self.context.response.success = ok + _set_answer(self.context, payload) + + +@R.register("memory_archive") +class MemoryArchive(BaseStep): + """Archive a file: flip `status: archived` then move to `/Archive/`.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") or "" + archive_dir_name: str = self.context.get("archive_dir", "Archive") or "Archive" + assert path, "path is required" + + watcher = self._get_component_optional(ComponentEnum.FILE_WATCHER, "default") + vault_root = Path(getattr(watcher, "watch_path", ".")).resolve() if watcher else Path.cwd() + + ok, payload = memory_io.write_archive(vault_root, path, archive_dir_name) + self.context.response.success = ok + _set_answer(self.context, payload) + + +@R.register("memory_count_tokens") +class MemoryCountTokens(BaseStep): + """Estimate tokens for a file body or raw text. One of `path`/`text` required.""" + + async def execute(self): + assert self.context is not None + path: str = self.context.get("path", "") or "" + text: str = self.context.get("text", "") or "" + result = await memory_io.count_tokens( + self.as_token_counter, + path=path or None, + text=text or None, + ) + self.context.response.success = "error" not in result + _set_answer(self.context, result) diff --git a/reme2/mcp/steps/memory_retriever.py b/reme2/mcp/steps/memory_retriever.py new file mode 100644 index 00000000..d4046c6d --- /dev/null +++ b/reme2/mcp/steps/memory_retriever.py @@ -0,0 +1,199 @@ +"""Memory retriever steps — thin MCP-facing wrappers over `BaseRetriever`. + +Per the architecture blueprint, retrieval policy (V+K hybrid + graph +BFS fusion + ranking + intent routing) is the **Retriever service** +(`reme2.memory.retriever`), registered as `ComponentEnum.RETRIEVER`. +These steps are the MCP projection: they translate `RuntimeContext` +(paths/tags/exclude_paths filter, per-call knob overrides) into the +retriever's call surface, then serialize results for the API response +(joining file metadata onto each chunk so callers don't have to fire a +`memory_get` per hit). + +Direct primary-key file ops (read/list/links/backlinks) live in +`memory_io` — those aren't retrieval. +""" + +from __future__ import annotations + +from ...component import R +from ...component.base_step import BaseStep +from ...component.runtime_response import _set_answer +from ...enumeration import ComponentEnum +from ...memory import memory_io +from ...memory.retriever import BaseRetriever, HybridRetriever + + +# Per-shell singleton cache: instantiating the retriever is cheap (it +# just stashes constructor knobs), but doing it once per call would +# still allocate every request. Keyed by step instance so each MCP +# job's overrides stay isolated. +_RETRIEVER_CACHE: dict[int, BaseRetriever] = {} + + +def _resolve_retriever(step: BaseStep) -> BaseRetriever: + """Get (or build) the retriever instance for this MCP step. + + The retriever is a registered Step (`@R.register("hybrid")`), but + it isn't pre-instantiated as a singleton component — there's no + RETRIEVER enum slot. Instead, each MCP shell builds its own + HybridRetriever the first time it's called, sharing the calling + step's `app_context` (so the lookup of `file_store`/`as_llm` works) + and forwarding any retriever knobs (`vector_weight`, `graph_*`, …) + from the step's kwargs as constructor defaults. + + Callers can also pass a pre-built `BaseRetriever` instance via + `kwargs["retriever"]` — useful for tests / Python callers that want + to inject a stub. + """ + injected = step.kwargs.get("retriever") + if isinstance(injected, BaseRetriever): + return injected + + cached = _RETRIEVER_CACHE.get(id(step)) + if cached is not None: + return cached + + backend = injected if isinstance(injected, str) else "hybrid" + cls = R.get(ComponentEnum.STEP, backend) + if cls is None or not (isinstance(cls, type) and issubclass(cls, BaseRetriever)): + # Fall back to the canonical implementation; lets configs that + # don't override `retriever` work out of the box. + cls = HybridRetriever + + knob_keys = ( + "vector_weight", "graph_weight", "graph_depth", "graph_decay", + "graph_direction", "graph_mode", "graph_per_path_cap", + "candidate_multiplier", "anchor_expand", "file_store", + ) + init_kwargs = {k: step.kwargs[k] for k in knob_keys if k in step.kwargs} + init_kwargs["app_context"] = step.app_context + + instance = cls(**init_kwargs) + _RETRIEVER_CACHE[id(step)] = instance + return instance + + +def _serialize_chunk(chunk, file_store, extras: dict | None = None) -> dict: + """Serialize a FileChunk for search-result payloads. + + Joins the owning file's metadata (frontmatter + st_mtime) so callers + don't have to fire a `memory_get` per result just to read `category`, + `status`, dates, etc. The join is a single in-memory dict lookup — + cost is negligible vs. the round-trip we save. + + `extras` lets the caller attach step-specific fields (e.g. `graph_hop`). + """ + item = chunk.model_dump(exclude_none=True, exclude={"embedding"}) + meta = file_store.get_file_meta(chunk.path) + if meta is not None: + item["file_metadata"] = meta.metadata + item["file_st_mtime"] = meta.st_mtime + else: + item["file_metadata"] = None + item["file_st_mtime"] = None + if extras: + item.update(extras) + return item + + +@R.register("memory_search") +class MemorySearch(BaseStep): + """Pure-relevance retrieval (V + K hybrid). Delegates to the Retriever service.""" + + async def execute(self): + assert self.context is not None, "Context is not set" + query: str = self.context.get("query", "").strip() + min_score: float = self.context.get("min_score", 0.1) + max_results: int = self.context.get("max_results", 5) + + assert query, "Query cannot be empty" + assert ( + isinstance(min_score, float | int) and 0.0 <= min_score <= 1.0 + ), f"min_score must be between 0 and 1, got {min_score}" + assert ( + isinstance(max_results, int) and max_results > 0 + ), f"max_results must be a positive integer, got {max_results}" + + chunk_filter = memory_io.make_chunk_filter( + self.file_store, + paths=self.context.get("paths") or None, + tags=self.context.get("tags") or None, + exclude_paths=self.context.get("exclude_paths") or None, + ) + + retriever = _resolve_retriever(self) + results = await retriever.search( + query=query, + max_results=max_results, + min_score=min_score, + chunk_filter=chunk_filter, + ) + + payload = [_serialize_chunk(r, self.file_store) for r in results] + _set_answer(self.context, payload) + + +@R.register("memory_graph_search") +class MemoryGraphSearch(BaseStep): + """V + K + graph BFS fusion. Delegates to the Retriever service. + + Per-call overrides for fusion knobs (vector_weight, graph_weight, + graph_depth, graph_decay, graph_direction, graph_mode, + graph_per_path_cap, anchor_expand) are forwarded if present in the + RuntimeContext; otherwise the retriever falls back to its own + constructor defaults. These knobs are intentionally NOT exposed via + MCP — they're internal-Python-caller tuning. + """ + + _OVERRIDE_KEYS = ( + "vector_weight", + "graph_weight", + "graph_depth", + "graph_decay", + "graph_direction", + "graph_mode", + "graph_per_path_cap", + "anchor_expand", + ) + + async def execute(self): + assert self.context is not None + ctx = self.context + + query: str = ctx.get("query", "").strip() + max_results: int = int(ctx.get("max_results", 5)) + min_score: float = float(ctx.get("min_score", 0.0)) + explicit_seeds: list[str] = list(ctx.get("seeds") or []) + + assert query or explicit_seeds, "query or seeds must be provided" + assert max_results > 0 + + chunk_filter = memory_io.make_chunk_filter( + self.file_store, + paths=ctx.get("paths") or None, + tags=ctx.get("tags") or None, + exclude_paths=ctx.get("exclude_paths") or None, + ) + + # Forward per-call overrides only if the caller actually set them; + # the retriever falls back to its own defaults for missing keys. + overrides = {k: ctx.get(k) for k in self._OVERRIDE_KEYS if ctx.get(k) is not None} + + retriever = _resolve_retriever(self) + results, hops = await retriever.graph_search( + query=query, + seeds=explicit_seeds, + max_results=max_results, + min_score=min_score, + chunk_filter=chunk_filter, + **overrides, + ) + + payload = [] + for c in results: + extras = {} + hop = hops.get(c.path) + if hop is not None: + extras["graph_hop"] = hop + payload.append(_serialize_chunk(c, self.file_store, extras)) + _set_answer(ctx, payload) diff --git a/reme2/mcp/steps/sync.py b/reme2/mcp/steps/sync.py new file mode 100644 index 00000000..09eb81d7 --- /dev/null +++ b/reme2/mcp/steps/sync.py @@ -0,0 +1,418 @@ +"""sync — continuously sync key materials into an event folder. + +Layout (one folder per logical thread): + + events/{date}/{name}/ + ├── {name}.md # index: frontmatter + narrative + Materials footer + ├── {material_filename} # raw artifact 1 + ├── ... + └── {material_filename} # raw artifact N + +Hot-path write entry. The agent picks a stable `name` per logical +thread and calls `sync` repeatedly through the task — each call +extends the same event folder rather than creating a new one. This +turns discrete writes into a coherent stream and lets PreCompact / +SessionEnd hooks treat sync as the last-chance flush before context +loss. + +Behavior: + * If `events/{date}/{name}/` does NOT exist → create new folder, + write index `{name}.md` (status=active), write materials. + * If it exists with `status: active` → APPEND: + - new content → `## Update — {iso}` section appended to the body + - new materials → siblings (auto-suffix on filename collision) + - Materials footer regenerated as the trailing section, listing + every artifact actually present in the folder + - frontmatter `topics` + `tags` unioned, `updated` set to today + * If it exists with `status: distilled` / `archived` → REFUSE, + return suggested_name so the agent can start a new thread. + +Zero LLM cost. +""" + +import json +import os +import re +from datetime import date as date_type, datetime, timezone +from pathlib import Path + +import frontmatter +from pydantic import ValidationError + +from reme2.component import R +from reme2.component.base_step import BaseStep +from reme2.memory.memory_io import write_create +from reme2.schema.vault import Event +from reme2.utils.vault_paths import event_path, next_suffixed_stem + + +_SAFE_FILENAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$") +_MATERIALS_HEADER = "## Materials" + + +@R.register("sync") +class Sync(BaseStep): + """Upsert into an event folder under events/{date}/{name}/. + + Inputs (RuntimeContext): + name (str, required): kebab-case event identifier; becomes both + the parent dir and the index filename stem. + Reuse the same name across calls in one + thread to keep extending the same folder. + description (str): one-line summary for index frontmatter + (only set on initial create). + content (str): markdown body. On create it's the initial + body; on append it's added under a new + `## Update — {iso}` section. + topics (list[str]): related topic wikilinks; merged (union) + into frontmatter on append. + tags (list[str]): free-form tags; merged (union) on append. + materials (list[dict]): [{filename, content}, ...] — raw artifacts + written as siblings of the index. Filename + collisions auto-suffix (foo.txt → foo-2.txt). + Index body's Materials footer regenerated + each call from actual folder contents. + on_date (str | None): ISO date for the events/{date}/ bucket. + Defaults to today. + origin_session_id (str): optional source session identifier (only + set on initial create). + + Output (context.response.answer): + JSON {path, materials: [paths of NEW materials this call], + created: bool, action: "created"|"appended"} on success; + {error, ...} on failure (including refusal when existing event + has status != "active"). + """ + + def __init__(self, vault_root: str = "", events_dir: str = "events", **kwargs): + super().__init__(**kwargs) + self.vault_root = vault_root + self.events_dir = events_dir + + def _root(self) -> Path: + if self.vault_root: + return Path(self.vault_root) + watcher = self.app_context.components["file_watcher"]["default"] # type: ignore[union-attr] + return Path(watcher.watch_path) + + @staticmethod + def _validate_materials(materials: list, index_filename: str) -> tuple[list[dict], str | None]: + """Sanity-check the materials list. Returns (cleaned, error_message).""" + cleaned: list[dict] = [] + seen_in_call: set[str] = set() + for i, m in enumerate(materials): + if not isinstance(m, dict): + return [], f"materials[{i}] must be an object {{filename, content}}" + fname = m.get("filename") + if not isinstance(fname, str) or not fname: + return [], f"materials[{i}].filename is required" + if not _SAFE_FILENAME_RE.match(fname): + return [], ( + f"materials[{i}].filename {fname!r} is unsafe — only " + f"letters / digits / dot / underscore / dash allowed" + ) + if fname == index_filename: + return [], f"materials[{i}].filename {fname!r} collides with the index file" + if fname in seen_in_call: + return [], f"materials[{i}].filename {fname!r} duplicated within the same call" + seen_in_call.add(fname) + content = m.get("content", "") + if not isinstance(content, str): + return [], f"materials[{i}].content must be a string" + cleaned.append({"filename": fname, "content": content}) + return cleaned, None + + @staticmethod + def _strip_materials_footer(body: str) -> str: + """Drop our trailing `## Materials` footer if present; return narrative.""" + if not body: + return "" + # Match the footer at end-of-doc: `## Materials\n\n- [...](./...)\n` repeated. + # Cheaper rule: find the LAST `## Materials` heading at line start; strip + # from there to EOF. Whatever the user wrote above stays intact. + m = re.search(r"(?:\A|\n)##\s+Materials[ \t]*\n", body) + if m is None: + return body.rstrip() + # Find the LAST such heading by scanning all matches. + last = None + for hit in re.finditer(r"(?:\A|\n)##\s+Materials[ \t]*\n", body): + last = hit + assert last is not None + cut = last.start() + # If the heading was at offset 0 (no leading \n), keep nothing before; + # otherwise keep up to (but not including) the leading \n. + return body[:cut].rstrip() + + @staticmethod + def _emit_body(narrative: str, material_filenames: list[str]) -> str: + """Assemble body = narrative (possibly empty) + Materials footer.""" + narrative = (narrative or "").rstrip() + if not material_filenames: + return f"{narrative}\n" if narrative else "" + listing = "\n".join(f"- [{f}](./{f})" for f in material_filenames) + if narrative: + return f"{narrative}\n\n{_MATERIALS_HEADER}\n\n{listing}\n" + return f"{_MATERIALS_HEADER}\n\n{listing}\n" + + @staticmethod + def _resolve_filename(existing: set[str], requested: str) -> str: + """Auto-suffix `foo.txt` → `foo-2.txt` (then -3, -4, …) on collision.""" + if requested not in existing: + return requested + if "." in requested: + stem, _, ext = requested.rpartition(".") + n = 2 + while f"{stem}-{n}.{ext}" in existing: + n += 1 + return f"{stem}-{n}.{ext}" + n = 2 + while f"{requested}-{n}" in existing: + n += 1 + return f"{requested}-{n}" + + @staticmethod + def _list_existing_materials(folder: Path, index_filename: str) -> list[str]: + """Filenames in `folder` excluding the index, sorted for stability.""" + if not folder.is_dir(): + return [] + return sorted( + entry.name for entry in folder.iterdir() + if entry.is_file() and entry.name != index_filename + ) + + @staticmethod + def _union(prior: list, incoming: list) -> list: + """Order-preserving union: keep prior order, append new items in input order.""" + out = list(prior) + seen = set(prior) + for item in incoming: + if item not in seen: + out.append(item) + seen.add(item) + return out + + def _set_error(self, payload: dict) -> None: + assert self.context is not None + self.context.response.success = False + self.context.response.answer = json.dumps(payload, ensure_ascii=False) + + def _set_ok(self, payload: dict) -> None: + assert self.context is not None + self.context.response.success = True + self.context.response.answer = json.dumps(payload, ensure_ascii=False) + + async def execute(self): + assert self.context is not None + name: str = self.context.get("name", "") or "" + description: str = self.context.get("description", "") or "" + content: str = self.context.get("content", "") or "" + topics: list[str] = list(self.context.get("topics") or []) + tags: list[str] = list(self.context.get("tags") or []) + materials_in = list(self.context.get("materials") or []) + on_date = self.context.get("on_date") + origin_session_id = self.context.get("origin_session_id") + + assert name, "name is required" + + target = event_path(self._root(), name, on_date, self.events_dir) + materials, mat_err = self._validate_materials(materials_in, target.name) + if mat_err is not None: + self._set_error({"error": mat_err}) + return + + if target.exists(): + await self._append(target, content, materials, topics, tags) + else: + await self._create( + target, name, description, content, materials, topics, tags, + on_date, origin_session_id, + ) + + async def _create( + self, target: Path, name: str, description: str, content: str, + materials: list[dict], topics: list[str], tags: list[str], + on_date, origin_session_id, + ) -> None: + today = date_type.today().isoformat() + on_date_str = ( + on_date.isoformat() if isinstance(on_date, date_type) + else (on_date or today) + ) + metadata: dict = { + "title": name, + "description": description, + "category": "event", + "status": "active", + "tags": tags, + "topics": topics, + "created": on_date_str, + "updated": today, + } + if origin_session_id: + metadata["originSessionId"] = origin_session_id + + try: + Event.model_validate(metadata) + except ValidationError as e: + self._set_error({ + "error": "Event schema validation failed", + "details": e.errors(include_context=False, include_url=False), + }) + return + + graph = self.file_store + conflicts = graph.collisions_after_create(target) + if conflicts: + taken = {Path(p).stem for p in graph.nodes} + suggested_name = next_suffixed_stem(taken, name) + self._set_error({ + "error": ( + f"stem `[[{name}]]` would resolve ambiguously " + f"to {len(conflicts) + 1} paths after this create" + ), + "conflicts": conflicts, + "suggested_name": suggested_name, + "hint": ( + f"retry with name='{suggested_name}', or pick a " + f"semantic qualifier (e.g. '{name}-followup')." + ), + }) + return + + material_filenames = [m["filename"] for m in materials] + index_body = self._emit_body(content, material_filenames) + ok, payload = write_create( + self.file_store, target, + metadata=metadata, content=index_body, + ) + if not ok: + self._set_error({ + "path": str(target.resolve()), + "error": payload.get("error", "create failed"), + "details": payload, + }) + return + + material_paths: list[str] = [] + for m in materials: + material_path = target.parent / m["filename"] + try: + material_path.write_text(m["content"], encoding="utf-8") + except Exception as e: + self.logger.warning( + f"sync: failed to write material {m['filename']}: {e}", + ) + continue + material_paths.append(str(material_path.resolve())) + + self._set_ok({ + "path": str(target.resolve()), + "category": "event", + "status": "active", + "topics": topics, + "materials": material_paths, + "created": True, + "action": "created", + }) + + async def _append( + self, target: Path, content: str, materials: list[dict], + topics: list[str], tags: list[str], + ) -> None: + # Read current frontmatter + body. + try: + raw = target.read_text(encoding="utf-8") + except Exception as e: + self._set_error({"path": str(target.resolve()), "error": f"read failed: {e}"}) + return + post = frontmatter.loads(raw) + meta = dict(post.metadata) + + status = meta.get("status") + if status != "active": + # Don't extend a distilled / archived thread — make the agent pick + # a new name so the prior cognition isn't silently mutated. + graph = self.file_store + taken = {Path(p).stem for p in graph.nodes} + base = target.stem + suggested = next_suffixed_stem(taken, base) + self._set_error({ + "path": str(target.resolve()), + "error": ( + f"event `{base}` already exists with status={status!r}; " + f"pick a new name to start a fresh thread" + ), + "status": status, + "suggested_name": suggested, + }) + return + + folder = target.parent + existing_filenames = self._list_existing_materials(folder, target.name) + existing_set = set(existing_filenames) + + # Resolve filename collisions for new materials. + resolved: list[tuple[str, str]] = [] # (filename_on_disk, content) + all_taken = set(existing_set) + for m in materials: + fname = self._resolve_filename(all_taken, m["filename"]) + all_taken.add(fname) + resolved.append((fname, m["content"])) + + # Write new materials to disk. + new_material_paths: list[str] = [] + for fname, mcontent in resolved: + material_path = folder / fname + try: + material_path.write_text(mcontent, encoding="utf-8") + except Exception as e: + self.logger.warning(f"sync: failed to write material {fname}: {e}") + continue + new_material_paths.append(str(material_path.resolve())) + existing_filenames.append(fname) + + # Rebuild the index body: narrative (existing + optional new Update + # section) + Materials footer (regenerated from disk). + narrative = self._strip_materials_footer(post.content) + if content.strip(): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + update_section = f"## Update — {ts}\n\n{content.rstrip()}\n" + narrative = ( + f"{narrative.rstrip()}\n\n{update_section}" + if narrative else update_section + ) + + all_filenames_sorted = sorted(set(existing_filenames)) + new_body = self._emit_body(narrative, all_filenames_sorted) + + # Update frontmatter: union topics/tags, bump updated. + meta["topics"] = self._union(list(meta.get("topics") or []), topics) + meta["tags"] = self._union(list(meta.get("tags") or []), tags) + meta["updated"] = date_type.today().isoformat() + + try: + Event.model_validate(meta) + except ValidationError as e: + self._set_error({ + "path": str(target.resolve()), + "error": "Event schema validation failed on append", + "details": e.errors(include_context=False, include_url=False), + }) + return + + new_post = frontmatter.Post(new_body, **meta) + try: + target.write_text(frontmatter.dumps(new_post), encoding="utf-8") + except Exception as e: + self._set_error({"path": str(target.resolve()), "error": f"write failed: {e}"}) + return + + self._set_ok({ + "path": str(target.resolve()), + "category": "event", + "status": "active", + "topics": meta["topics"], + "materials": new_material_paths, + "created": False, + "action": "appended", + }) diff --git a/reme2/mcp/steps/topic_create.py b/reme2/mcp/steps/topic_create.py new file mode 100644 index 00000000..50e34baa --- /dev/null +++ b/reme2/mcp/steps/topic_create.py @@ -0,0 +1,142 @@ +"""topic_create — create a new topic under topics/{folder}/{name}.md. + +The only file-creation entry point for topics. Enforces: + - path template (topics/{folder}/{name}.md) + - folder topic identification (folder == name) + - judgment-category strong-confidence (via Topic.model_validator) + - wikilink uniqueness (creating this file must not make `[[name]]` ambiguous) + +Writes via `write_create` — the canonical L1 invariant gate that also +performs the wikilink-uniqueness check. The Ingestor's R-M-W loop is +reserved for content-driven flows; topic creation is path-driven, so +the direct call is sufficient. +""" + +import json +from datetime import date +from pathlib import Path + +from pydantic import ValidationError + +from reme2.component import R +from reme2.component.base_step import BaseStep +from reme2.memory.memory_io import write_create +from reme2.schema.vault import Topic +from reme2.utils.vault_paths import next_suffixed_stem, topic_path + + +@R.register("topic_create") +class TopicCreate(BaseStep): + """Create a topic file with the standard frontmatter template.""" + + def __init__(self, vault_root: str = "", topics_dir: str = "topics", **kwargs): + super().__init__(**kwargs) + self.vault_root = vault_root + self.topics_dir = topics_dir + + def _root(self) -> Path: + if self.vault_root: + return Path(self.vault_root) + watcher = self.app_context.components["file_watcher"]["default"] # type: ignore[union-attr] + return Path(watcher.watch_path) + + async def execute(self): + assert self.context is not None + folder: str = self.context.get("folder", "") + name: str = self.context.get("name", "") + category: str = self.context.get("category", "") + description: str = self.context.get("description", "") or "" + content: str = self.context.get("content", "") or "" + confidence = self.context.get("confidence") + market = self.context.get("market") + ticker = self.context.get("ticker") + tags: list[str] = self.context.get("tags") or [] + + assert folder, "folder is required" + assert name, "name is required" + assert category, "category is required" + + target = topic_path(self._root(), folder, name, self.topics_dir) + if target.exists(): + self.context.response.success = False + self.context.response.answer = json.dumps({ + "path": str(target.resolve()), + "error": "topic already exists; use memory_update / memory_property_update to modify", + }, ensure_ascii=False) + return + + today = date.today().isoformat() + metadata: dict = { + "title": name, + "description": description, + "category": category, + "tags": tags, + "created": today, + "updated": today, + } + if confidence is not None: + metadata["confidence"] = confidence + if market is not None: + metadata["market"] = market + if ticker is not None: + metadata["ticker"] = ticker + + # Topic schema validation (judgment categories require confidence). + try: + Topic.model_validate(metadata) + except ValidationError as e: + self.context.response.success = False + self.context.response.answer = json.dumps({ + "error": "Topic schema validation failed", + "details": e.errors(include_context=False, include_url=False), + }, ensure_ascii=False) + return + + # Pre-check uniqueness so we can surface a `suggested_name` to the + # agent. The actual write also routes through the same gate inside + # write_create, so this is a UX nicety, not a correctness check. + graph = self.file_store + conflicts = graph.collisions_after_create(target) + if conflicts: + taken = {Path(p).stem for p in graph.nodes} + suggested_name = next_suffixed_stem(taken, name) + self.context.response.success = False + self.context.response.answer = json.dumps({ + "error": ( + f"stem `[[{name}]]` would resolve ambiguously " + f"to {len(conflicts) + 1} paths after this create" + ), + "conflicts": conflicts, + "suggested_name": suggested_name, + "hint": ( + f"retry with name='{suggested_name}' (numeric suffix), " + f"OR use a domain-specific qualifier (e.g. '{name}-Inc' / " + f"'{name}-v2') — semantic names beat numeric. If you " + f"actually meant the existing topic, call memory_get on " + f"one of `conflicts` instead." + ), + }, ensure_ascii=False) + return + + ok, payload = write_create( + self.file_store, target, + metadata=metadata, content=content, + ) + + if not ok: + self.context.response.success = False + self.context.response.answer = json.dumps({ + "path": str(target.resolve()), + "error": payload.get("error", "create failed"), + "details": payload, + }, ensure_ascii=False) + return + + is_folder_topic = folder == name + self.context.response.success = True + self.context.response.answer = json.dumps({ + "path": str(target.resolve()), + "category": category, + "is_folder_topic": is_folder_topic, + "created": True, + }, ensure_ascii=False) diff --git a/reme2/memory/__init__.py b/reme2/memory/__init__.py new file mode 100644 index 00000000..f1d48797 --- /dev/null +++ b/reme2/memory/__init__.py @@ -0,0 +1,22 @@ +"""Memory subsystem — the three services on top of the core engine. + +Per the architecture blueprint: + + - retriever.py Read service. V + K + Graph BFS fusion + intent + routing. Registered as a Step (`backend: hybrid`); + MCP step shells in `reme2.mcp.steps.memory_retriever` + instantiate it on demand. + - ingestor.py Cold-write service. LLM-driven R-M-W curator. + Triggered on explicit handoff (task end / SessionEnd). + - maintainer.py Treatment service. Background Merge / Split / Decay / + Lint, woken by cron or thresholds. + - summarizer.py Auxiliary used by the services. + +Hot-write MCP step shells (sync, topic_create, memory_*) live in +`reme2.mcp.steps`, NOT here — they bypass services and write MFS +directly. Importing this package triggers @R.register on the three +services so configs that name them resolve at boot. +""" + +from . import retriever # noqa: F401 -- runs @R.register("hybrid") +from . import maintainer # noqa: F401 -- runs @R.register("maintainer") diff --git a/reme2/memory/ingestor.py b/reme2/memory/ingestor.py new file mode 100644 index 00000000..9dfc71a5 --- /dev/null +++ b/reme2/memory/ingestor.py @@ -0,0 +1,187 @@ +"""Smart Ingestor — ReAct agent over the Memory File System. + +Per `structure.md` L31-44, the Ingestor is the SSOT engine: the **single +write entry point** to the markdown vault. Every mutation (create, body +edit, frontmatter flip, rename, delete, archive) flows through here. + +Mirrors `Summarizer`'s pattern — drives a `ReActAgent` whose toolkit is +built by `memory_io.build_memory_toolkit`. The agent runs its own R-M-W +loop: read related files via tools, decide which ones to mutate, call +the right write tool. Every write tool records into an audit list, so +the caller gets a deterministic mutation trail regardless of how the +agent's reasoning unfolded. + +When no LLM is configured, falls back to a direct create from +`target_path` + `metadata` + `content`. Edits/renames/deletes are not +available without an LLM. +""" + +from __future__ import annotations + +import datetime +import json +import zoneinfo +from pathlib import Path + +from agentscope.agent import ReActAgent +from agentscope.message import Msg +from agentscope.tool import Toolkit +from pydantic import BaseModel, Field + +from ..component.runtime_response import _set_answer, _to_jsonable +from . import memory_io +from .memory_io import MemoryIO, write_create +from ..component import R +from ..component.base_step import BaseStep +from ..enumeration import ComponentEnum +from ..utils.wikilink import extract_wikilinks + + +class IngestResult(BaseModel): + """Audit trail for a single ingest call.""" + + applied: list[dict] = Field(default_factory=list, description="Successful ops with paths.") + rejected: list[dict] = Field(default_factory=list, description="Ops the validator refused.") + failed: list[dict] = Field(default_factory=list, description="Ops that errored at apply time.") + skipped: bool = Field(default=False, description="True if the LLM returned a SkipOp.") + used_llm: bool = Field(default=False, description="False = degraded path (no LLM configured).") + + @property + def success(self) -> bool: + """Skipped or any-applied with no failures = success.""" + if self.skipped: + return True + return len(self.applied) > 0 and len(self.failed) == 0 + + +@R.register("ingestor") +class Ingestor(BaseStep): + """R-M-W ingestor exposed as a single-write-entry MCP tool. + + Inputs (read from RuntimeContext): + content (str, required): the material being ingested. + hint (str, optional): caller guidance to the LLM. + target_path (str, optional): suggested file path; required for + the degraded path (no LLM). + metadata (dict, optional): suggested frontmatter; used by + the degraded path or as a hint to the LLM. + related_paths (list[str], optional): explicit related files, + auto-extended with wikilinks parsed from `content`. + + Output (written to context.response.answer): + IngestResult JSON — applied/failed lists plus used_llm flag and + the agent's final-message summary. + """ + + def __init__( + self, + toolkit: Toolkit | None = None, + console_enabled: bool = False, + timezone: str | None = None, + **kwargs, + ): + super().__init__(**kwargs) + self.toolkit = toolkit + self.console_enabled = console_enabled + self.timezone = timezone + + def _now(self) -> datetime.datetime: + if self.timezone: + try: + return datetime.datetime.now(zoneinfo.ZoneInfo(self.timezone)) + except Exception as e: + self.logger.error(f"Invalid timezone: {self.timezone}, error={e}") + return datetime.datetime.now() + + def _vault_root(self) -> Path: + watcher = self._get_component_optional(ComponentEnum.FILE_WATCHER, "default") + if watcher is None: + vr = getattr(self.file_store, "vault_root", None) + return Path(vr).resolve() if vr else Path.cwd().resolve() + return Path(getattr(watcher, "watch_path", ".")).resolve() + + async def execute(self): + assert self.context is not None + content: str = self.context.get("content", "") or "" + hint: str = self.context.get("hint", "") or "" + target_path: str = self.context.get("target_path") or "" + metadata: dict = dict(self.context.get("metadata") or {}) + related_paths: list[str] = list(self.context.get("related_paths") or []) + + assert content, "content is required" + + # Auto-discover wikilink targets in content as a hint for the agent. + for link in extract_wikilinks(content): + hit = memory_io.wikilink_lookup(self.file_store, link)["path"] + if hit and hit not in related_paths: + related_paths.append(hit) + + as_llm = self._get_component_optional(ComponentEnum.AS_LLM, "default", "model") + if as_llm is None: + result = self._degraded(target_path, metadata, content) + self.context.response.success = result.success + _set_answer(self.context, result.model_dump()) + return + + vault_root = self._vault_root() + mio = MemoryIO(self.file_store, vault_root) + toolkit = mio.register_all(self.toolkit) + + agent = ReActAgent( + name="reme_ingestor", + model=self.as_llm, + sys_prompt=self.prompt_format("system_prompt", vault_root=str(vault_root)), + formatter=self.as_llm_formatter, + toolkit=toolkit, + ) + agent.set_console_output_enabled(self.console_enabled) + + user_message: str = self.prompt_format( + "user_message", + today=self._now().strftime("%Y-%m-%d"), + vault_root=str(vault_root), + hint=hint or "(none)", + target_path=target_path or "(none)", + metadata=json.dumps(_to_jsonable(metadata), ensure_ascii=False), + related=json.dumps(related_paths, ensure_ascii=False), + content=content, + ) + + final_msg: Msg = await agent.reply( + Msg(name="reme", role="user", content=user_message), + ) + summary = final_msg.get_text_content() or "" + + result = IngestResult(used_llm=True) + for entry in mio.audit: + (result.applied if entry.get("ok") else result.failed).append(entry) + if not mio.audit and summary.strip().upper().startswith("SKIP"): + result.skipped = True + + self.context.response.success = result.success + payload = result.model_dump() + payload["agent_summary"] = summary + _set_answer(self.context, payload) + + def _degraded(self, target_path: str, metadata: dict, content: str) -> IngestResult: + """Without an LLM, only direct create from explicit target_path is + supported. Useful for tests and bootstrap scripts.""" + result = IngestResult(used_llm=False) + if not target_path: + result.failed.append({ + "op": "create", + "ok": False, + "error": "degraded path: target_path is required when no LLM is configured", + }) + return result + path = Path(target_path) + if not path.is_absolute(): + path = self._vault_root() / path + ok, payload = write_create( + self.file_store, path, + metadata=metadata, content=content, + ) + bucket = result.applied if ok else result.failed + bucket.append({"op": "create", "ok": ok, "path": str(path), "result": payload}) + return result + diff --git a/reme2/memory/ingestor.yaml b/reme2/memory/ingestor.yaml new file mode 100644 index 00000000..33f37c12 --- /dev/null +++ b/reme2/memory/ingestor.yaml @@ -0,0 +1,110 @@ +system_prompt: | + You are the memory curator for a markdown vault — the LLM-driven + R-M-W loop over EXISTING files. You only run when the agent + EXPLICITLY HANDS OFF: at task completion, at session end, or when + the agent decides the working set is ready to be distilled. By that + point the agent has typically called `sync` along the way to + land raw facts as event folders. Your job: take whatever materials + the agent feeds you (inline text and/or paths), read them + any + related topics, decide which existing topics need updates, which + deserve a new topic, then flip each distilled event's status. + + vault_root: {vault_root} + + # Scope of THIS tool + - You are the COLD path. You DO NOT run on every turn — only on + explicit handoff (task done / session ending / agent invokes you + directly). The hot path (`sync`, deterministic, no LLM) + has already preserved raw facts continuously through the task. + - The agent supplies the working set in two interchangeable forms: + * `content` — inline material the agent is giving you directly + (a distillation hint, a session summary, raw text it wants + folded into the graph). + * `related_paths` — paths the agent points you at (event folder + indexes, individual material files, candidate topics it flagged + for update). Both forms can appear together; treat them as a + single working set. + - An event is a FOLDER containing the index `{name}.md` plus + materials (raw conversation snippets, tool outputs, data dumps). + Whenever a path in `related_paths` is an event index, `memory_get` + the index first, then `memory_get` any materials whose content you + need (the index lists them under `## Materials`). + + # Vault conventions + - Topics live under `topics/{{folder}}/{{name}}.md`. A folder topic + has folder == name. + - Events live under `events/{{YYYY-MM-DD}}/{{name}}/{{name}}.md` — + this is an INDEX inside a folder; sibling files are materials. + - Frontmatter is YAML; `category`, `created`, `updated`, `tags`, + `status` are common. + - Cross-file references use `[[wikilink]]` syntax (stem-form `[[X]]` + or path-form `[[topics/X/X]]`). + + # Available tools + Read tools (use these to gather context BEFORE writing): + - memory_get(path, include_chunks=False): full file content + frontmatter. + Call this on each event index AND on the materials it lists. + - memory_list(path_prefix=None, tags=None, metadata=None, limit=100): + list indexed files filtered by prefix / tags / frontmatter. + - memory_resolve_wikilink(wikilink): resolve `[[X]]` to a path. + - memory_backlinks(path): files linking to a given path. + - memory_links(path): files a given path links to. + + Write tools (each returns success + payload; mutations are SSOT-routed): + - memory_update(path, old_string, new_string, replace_all=False): + body edit by exact-string substitution. Use a tail snippet to append. + - memory_property_update(path, key, value): change one frontmatter + key (value=null deletes it). After distilling an event into one or + more topics, flip that event's status to "distilled" with this. + - memory_create(path, metadata, content, overwrite=False, force=False): + new file. Reserve for genuinely NEW topics — do NOT use this to log + events; `sync` (deterministic) owns events. ALL paths must + be ABSOLUTE under vault_root. + - memory_rename(old_path, new_path): move file + rewrite cross-vault + wikilinks. + - memory_delete(path): remove a file. + - memory_archive(path): flip `status: archived` and move under + `/Archive/`. + + # Decision rules + 1. If material is ALREADY covered by existing topics → reply with a + single line `SKIP: ` and call no tools. + 2. If material CONTRADICTS an existing block → memory_update with a + unique snippet of the outdated text and the corrected replacement. + 3. If material EXTENDS an existing topic → memory_update using a + unique TAIL snippet of the existing body, with new_string = + tail + blank line + new content. + 4. If material warrants a GENUINELY NEW topic → memory_create at + `topics/{{folder}}/{{name}}.md`. Do NOT memory_create under events/. + 5. After integrating an event's content into a topic, flip that event's + status to "distilled" with memory_property_update. + 6. Never delete unless the material explicitly asks for deletion. + 7. Keep edits minimal — read related files first, edit only what + must change. + 8. Always include reasonable frontmatter on memory_create — at minimum + `title`, `category`, `created`, `updated`. Use today's date for + `created` / `updated`. + 9. After all writes, end with a one-paragraph summary of what you did + and why. + +user_message: | + # CONTEXT + today: {today} + vault_root: {vault_root} + + # WORKING SET (handed off by the agent) + caller hint: {hint} + target_path hint: {target_path} + metadata hint: {metadata} + related paths (auto-discovered from wikilinks in content + caller's related_paths): {related} + + content (inline material the agent is feeding you — distillation + hint, session summary, or raw text): + {content} + + Treat `content` (inline) and `related paths` (pointers) as a single + working set. For each related path that's an event index, read the + index then read any materials it lists. Inspect the related topics, + then perform the minimal set of writes needed to distill this + material into the topic graph. Flip any distilled event's status as + part of the same call. End with a one-paragraph summary. diff --git a/reme2/memory/maintainer.py b/reme2/memory/maintainer.py new file mode 100644 index 00000000..4776d225 --- /dev/null +++ b/reme2/memory/maintainer.py @@ -0,0 +1,511 @@ +"""Maintainer — treatment service. One pass, four signals, one plan. + +Per the architecture blueprint, the Maintainer is the third memory +service alongside the Retriever (read) and the Ingestor (cold-write). +It's woken by cron or thresholds, not by per-turn agent calls. + +DESIGN: single `Maintainer` Step, NOT four. Vault hygiene is one +operation with four signal sources that contend for the same files — +splitting them into independent Steps would let merge & split reverse +each other, let decay archive a file that merge wanted to absorb, and +force every cron tick to scan the vault four times. So the Maintainer +follows a plan-then-apply pipeline: + + scan_signals() # one walk, all signals shared + ↓ + propose_*() # each signal source emits Op records + ↓ + resolve_conflicts() # data-driven matrix dedupes / orders ops + ↓ + apply() # lint → decay → merge → split, dry-run aware + ↓ + audit # unified trail returned to caller + +OPERATIONS + + LintFinding read-only diagnostic; never conflicts. + DecayOp move a stale event under //. + MergeOp absorb sources[] into canonical; rewrite incoming + wikilinks; archive sources. + SplitOp extract sections of source[] into new files; replace + in original with [[…]] stubs. + +CONFLICT MATRIX (resolved before apply) + + MergeOp(source=P) ⊕ SplitOp(source=P) → drop split + MergeOp(source=P) ⊕ DecayOp(path=P) → drop decay + SplitOp(source=P) ⊕ DecayOp(path=P) → drop split + MergeOp(canonical=A,…) × N (same A) → union sources + SplitOp(source=P) × N → keep highest confidence + LintFinding ⊕ anything → coexist + +APPLY ORDER is fixed: lint → decay → merge → split. This guarantees +that by the time split runs, merge has already rewritten paths; if a +split target was absorbed by a merge, apply skips it and records a +`stale_target` audit entry instead of crashing. + +LLM-DRIVEN PROPOSERS (merge/split) are scaffolded — the structure + +op shape + conflict path are wired and verified, but the LLM-backed +similarity / split heuristics are pending design and currently return +empty proposal lists. Lint and decay are fully implemented. +""" + +from __future__ import annotations + +import datetime +from collections import defaultdict +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +from ..component import R +from ..component.base_step import BaseStep +from ..component.runtime_response import _set_answer +from ..enumeration import ComponentEnum +from ..schema.vault.registry import schema_for +from . import memory_io + + +# --------------------------------------------------------------------------- +# Op records +# --------------------------------------------------------------------------- + + +class LintFinding(BaseModel): + """Read-only diagnostic. Never conflicts; never mutates.""" + + op: Literal["lint"] = "lint" + path: str + kind: Literal["broken_wikilink", "schema_violation", "stem_collision"] + detail: str + + +class DecayOp(BaseModel): + """Archive a stale event. Moves the file; doesn't change body.""" + + op: Literal["decay"] = "decay" + path: str + age_days: int + reason: str = "past freshness window" + + +class MergeOp(BaseModel): + """Consolidate `sources` into `canonical`. Sources get archived.""" + + op: Literal["merge"] = "merge" + canonical: str + sources: list[str] = Field(default_factory=list) + confidence: float = 0.0 + reason: str = "" + + +class SplitOp(BaseModel): + """Extract sections of `source` into new sibling files.""" + + op: Literal["split"] = "split" + source: str + sections: list[dict] = Field(default_factory=list) # [{title, body, target_path}] + confidence: float = 0.0 + reason: str = "" + + +# --------------------------------------------------------------------------- +# Signals: one scan, all observers consume the same dict +# --------------------------------------------------------------------------- + + +class FileSignal(BaseModel): + """Per-file derived signals shared by every proposer. + + Built once by `_scan_signals`. Holds only what's cheap to compute + from `file_store.nodes` + edge index — no body reads, no LLM calls. + Heavier signals (token counts, embeddings) are pulled lazily inside + the proposers that actually need them. + """ + + path: str + relpath: str = "" # path relative to vault_root, "" if outside + category: str = "" + status: str = "" + age_days: int = 0 + metadata: dict = Field(default_factory=dict) + declared_topics: list[str] = Field(default_factory=list) # raw [[…]] strings + + +# --------------------------------------------------------------------------- +# Maintainer +# --------------------------------------------------------------------------- + + +@R.register("maintainer") +class Maintainer(BaseStep): + """Unified vault hygiene. Scan → propose → resolve → apply. + + Reads from RuntimeContext (all optional): + ops (list[str], default ["lint","decay"]): + subset of {"lint","decay","merge","split"} to run. + Merge/split require an LLM and are off by default. + dry_run (bool, default True): if True, returns the plan + but doesn't mutate the vault. + decay_days (int, default constructor `decay_days`): freshness + window for the decay proposer. + target_prefix (str, default ""): restrict scan to relpaths + starting with this prefix (e.g. "events/"). + token_threshold (int, default constructor): split threshold. + merge_threshold (float, default constructor): cluster cutoff. + + Writes to ctx.response.answer: + { + "ops_run": [...], # which proposers ran + "scanned": int, + "proposed": [op,...], # raw, before conflict resolution + "plan": [op,...], # after conflict resolution + "applied": [op,...], # successfully mutated + "skipped": [op,...], # dropped by conflict resolution + "failed": [op,...], # apply errored + "dry_run": bool, + "ran_at": iso, + } + """ + + # Fixed apply order — see module docstring CONFLICT MATRIX section. + _APPLY_ORDER = ("lint", "decay", "merge", "split") + + def __init__( + self, + decay_days: int = 90, + archive_dir: str = "Archive", + target_status: str = "distilled", + token_threshold: int = 4000, + merge_threshold: float = 0.85, + **kwargs, + ): + super().__init__(**kwargs) + self.decay_days = decay_days + self.archive_dir = archive_dir + self.target_status = target_status + self.token_threshold = token_threshold + self.merge_threshold = merge_threshold + + # -- entry point -------------------------------------------------------- + + async def execute(self): + assert self.context is not None + params = self._load_params() + + signals = self._scan_signals(target_prefix=params["target_prefix"]) + + proposed: list[BaseModel] = [] + if "lint" in params["ops"]: + proposed.extend(self._propose_lint(signals)) + if "decay" in params["ops"]: + proposed.extend(self._propose_decay(signals, params["decay_days"])) + if "merge" in params["ops"]: + proposed.extend(await self._propose_merge(signals, params["merge_threshold"])) + if "split" in params["ops"]: + proposed.extend(await self._propose_split(signals, params["token_threshold"])) + + plan, dropped = self._resolve_conflicts(proposed) + + applied: list[dict] = [] + failed: list[dict] = [] + if not params["dry_run"]: + applied, failed = await self._apply(plan) + + audit = { + "ops_run": params["ops"], + "scanned": len(signals), + "proposed": [_dump(o) for o in proposed], + "plan": [_dump(o) for o in plan], + "applied": applied, + "skipped": [_dump(o) for o in dropped], + "failed": failed, + "dry_run": params["dry_run"], + "ran_at": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"), + } + self.context.response.success = len(failed) == 0 + _set_answer(self.context, audit) + + # -- params ------------------------------------------------------------- + + def _load_params(self) -> dict: + """Pull RuntimeContext kwargs with proper defaults. + + Centralizing this kills the `int(ctx.get(...))` / `float(ctx.get(...))` + pattern that bites when `.get` returns None — every conversion + below has a guaranteed-non-None operand. + """ + ctx = self.context + assert ctx is not None + ops = ctx.get("ops") or ["lint", "decay"] + if not isinstance(ops, list): + raise ValueError(f"ops must be a list of strings, got {type(ops).__name__}") + bad = [o for o in ops if o not in self._APPLY_ORDER] + if bad: + raise ValueError(f"unknown ops: {bad}; allowed: {list(self._APPLY_ORDER)}") + return { + "ops": ops, + "dry_run": bool(ctx.get("dry_run", True)), + "decay_days": int(ctx.get("decay_days") or self.decay_days), + "target_prefix": str(ctx.get("target_prefix") or ""), + "token_threshold": int(ctx.get("token_threshold") or self.token_threshold), + "merge_threshold": float(ctx.get("merge_threshold") or self.merge_threshold), + } + + # -- scan --------------------------------------------------------------- + + def _vault_root(self) -> Path | None: + watcher = self._get_component_optional(ComponentEnum.FILE_WATCHER, "default") + if watcher is None: + return None + return Path(getattr(watcher, "watch_path", ".")).resolve() + + def _scan_signals(self, *, target_prefix: str = "") -> list[FileSignal]: + """One walk over the indexed files. Cheap signals only.""" + vault_root = self._vault_root() + now = datetime.datetime.now().timestamp() + signals: list[FileSignal] = [] + for path, meta in memory_io.iter_files(self.file_store): + relpath = "" + if vault_root is not None: + try: + relpath = str(Path(path).resolve().relative_to(vault_root)) + except ValueError: + pass + if target_prefix and not relpath.startswith(target_prefix): + continue + fm = meta.metadata or {} + age_seconds = max(0.0, now - (meta.st_mtime or now)) + signals.append(FileSignal( + path=path, + relpath=relpath, + category=str(fm.get("category") or ""), + status=str(fm.get("status") or ""), + age_days=int(age_seconds // 86400), + metadata=fm, + declared_topics=list(fm.get("topics") or []), + )) + return signals + + # -- proposers ---------------------------------------------------------- + + def _propose_lint(self, signals: list[FileSignal]) -> list[LintFinding]: + """Broken wikilinks + frontmatter schema violations + stem collisions.""" + out: list[LintFinding] = [] + for sig in signals: + for link in sig.declared_topics: + if not memory_io.wikilink_lookup(self.file_store, link)["exists"]: + out.append(LintFinding( + path=sig.path, kind="broken_wikilink", + detail=f"unresolved wikilink {link!r}", + )) + cls = schema_for(sig.category) + if cls is not None: + try: + cls(**sig.metadata) + except Exception as e: + out.append(LintFinding( + path=sig.path, kind="schema_violation", + detail=f"{cls.__name__}: {type(e).__name__}: {e}"[:240], + )) + # Stem collisions: the engine API exposes the ambiguous-stem map. + ambig = memory_io.all_ambiguous_wikilinks(self.file_store) + for stem, paths in ambig.items(): + for p in paths: + out.append(LintFinding( + path=p, kind="stem_collision", + detail=f"stem {stem!r} also claimed by {[x for x in paths if x != p]}", + )) + return out + + def _propose_decay( + self, signals: list[FileSignal], decay_days: int, + ) -> list[DecayOp]: + """Distilled events past the freshness window.""" + out: list[DecayOp] = [] + for sig in signals: + if sig.category != "event": + continue + if sig.status != self.target_status: + continue + if sig.age_days < decay_days: + continue + out.append(DecayOp( + path=sig.path, age_days=sig.age_days, + reason=f"event {sig.status!r} for {sig.age_days}d (≥{decay_days}d window)", + )) + return out + + async def _propose_merge( + self, signals: list[FileSignal], threshold: float, + ) -> list[MergeOp]: + """Cluster near-duplicate topics → MergeOps (LLM-assisted). + + SCAFFOLD: the structure is wired but the clustering implementation + is pending. Returns []. When implemented: + 1. Embed titles + descriptions; cluster on cosine ≥ threshold. + 2. For each cluster, ask the LLM to pick the canonical and + summarize what the merged body should preserve. + 3. Emit MergeOp(canonical, sources=[non-canonical], confidence, + reason=LLM justification). + """ + return [] + + async def _propose_split( + self, signals: list[FileSignal], token_threshold: int, + ) -> list[SplitOp]: + """Topics over the token threshold → SplitOps (LLM-assisted). + + SCAFFOLD: returns []. When implemented: + 1. Filter signals to topics whose body exceeds token_threshold. + 2. Read body, ask the LLM for a section partition + filenames. + 3. Emit SplitOp(source, sections=[{title,body,target_path}], + confidence, reason). + """ + return [] + + # -- conflict resolution ------------------------------------------------ + + def _resolve_conflicts( + self, proposed: list[BaseModel], + ) -> tuple[list[BaseModel], list[BaseModel]]: + """Apply the conflict matrix; return (kept_plan, dropped). + + Steps: + 1. Union MergeOps that share a canonical. + 2. Dedupe SplitOps by source (highest confidence wins). + 3. For each path, resolve {merge-source, split-source, decay} + contention per the matrix in the module docstring. + 4. Lint findings are pass-through. + """ + lints = [o for o in proposed if isinstance(o, LintFinding)] + merges = [o for o in proposed if isinstance(o, MergeOp)] + splits = [o for o in proposed if isinstance(o, SplitOp)] + decays = [o for o in proposed if isinstance(o, DecayOp)] + + dropped: list[BaseModel] = [] + + # (1) Union merges by canonical. + merged_by_canon: dict[str, MergeOp] = {} + for m in merges: + existing = merged_by_canon.get(m.canonical) + if existing is None: + merged_by_canon[m.canonical] = m + else: + merged_sources = list(dict.fromkeys(existing.sources + m.sources)) + existing.sources = merged_sources + existing.confidence = max(existing.confidence, m.confidence) + existing.reason = (existing.reason + "; " + m.reason).strip("; ") + merges = list(merged_by_canon.values()) + + # (2) Dedupe splits by source. + split_by_source: dict[str, SplitOp] = {} + for s in splits: + current = split_by_source.get(s.source) + if current is None or s.confidence > current.confidence: + if current is not None: + dropped.append(current) + split_by_source[s.source] = s + else: + dropped.append(s) + splits = list(split_by_source.values()) + + # (3) Build path → owning op index. + merge_paths: set[str] = set() + for m in merges: + merge_paths.update(m.sources) + merge_paths.add(m.canonical) + + kept_splits: list[SplitOp] = [] + for s in splits: + if s.source in merge_paths: + dropped.append(s) # MergeOp ⊕ SplitOp(source=P) → drop split + else: + kept_splits.append(s) + + split_sources = {s.source for s in kept_splits} + + kept_decays: list[DecayOp] = [] + for d in decays: + if d.path in merge_paths: + dropped.append(d) # MergeOp ⊕ DecayOp(P) → drop decay + elif d.path in split_sources: + # SplitOp(source=P) ⊕ DecayOp(P) → matrix says drop split, + # but split was kept above (no merge contention) so the + # decay wins here: archive trumps refining what's about + # to leave the active set. + # → drop the split, keep decay. + for s in list(kept_splits): + if s.source == d.path: + kept_splits.remove(s) + dropped.append(s) + kept_decays.append(d) + else: + kept_decays.append(d) + + plan: list[BaseModel] = [] + plan.extend(lints) + plan.extend(kept_decays) + plan.extend(merges) + plan.extend(kept_splits) + return plan, dropped + + # -- apply -------------------------------------------------------------- + + async def _apply( + self, plan: list[BaseModel], + ) -> tuple[list[dict], list[dict]]: + """Execute the plan in fixed order. Each phase yields audit dicts. + + Lint never mutates → recorded as applied no-op so the audit shows + which findings the cron run surfaced. + + Decay / Merge / Split call into the existing memory_io primitives + once the heuristics land; for now they record `pending_apply` so + the dry_run=False path still produces a stable audit shape. + """ + applied: list[dict] = [] + failed: list[dict] = [] + + # Group ops by type, apply in fixed order. + by_type: dict[str, list] = defaultdict(list) + for o in plan: + by_type[o.op].append(o) # type: ignore[attr-defined] + + for kind in self._APPLY_ORDER: + for op in by_type.get(kind, []): + try: + result = await self._apply_one(op) + applied.append({"op": op.op, **result}) # type: ignore[attr-defined] + except Exception as e: + failed.append({ + "op": op.op, # type: ignore[attr-defined] + "payload": _dump(op), + "error": f"{type(e).__name__}: {e}", + }) + return applied, failed + + async def _apply_one(self, op: BaseModel) -> dict: + if isinstance(op, LintFinding): + # Diagnostic-only: surfacing the finding IS the work. + return {"path": op.path, "kind": op.kind, "noop": True} + if isinstance(op, DecayOp): + # TODO: wire to MemoryArchive once we're ready to actually + # move files from a cron context. For now, surface intent. + return {"path": op.path, "status": "pending_apply", + "would": "flip status=archived + move to archive_dir"} + if isinstance(op, MergeOp): + return {"canonical": op.canonical, "sources": op.sources, + "status": "pending_apply", + "would": "merge bodies + rewrite incoming wikilinks + archive sources"} + if isinstance(op, SplitOp): + return {"source": op.source, "sections": len(op.sections), + "status": "pending_apply", + "would": "extract sections + replace with [[…]] stubs"} + raise TypeError(f"unknown op type: {type(op).__name__}") + + +def _dump(op: BaseModel) -> dict: + """JSON-friendly snapshot of an op record.""" + return op.model_dump() diff --git a/reme2/memory/memory_io.py b/reme2/memory/memory_io.py new file mode 100644 index 00000000..ed89b019 --- /dev/null +++ b/reme2/memory/memory_io.py @@ -0,0 +1,849 @@ +"""Memory File System engine API — the core engine's outward surface. + +The .md files are the SSOT (per `structure.md` §"核心引擎"). The engine +is layered: + + Memory File System → Watcher & Parser → Projections (vector / FTS / graph) + (write entry) (incremental) (read entry, derived) + +This module is the **single public API surface** over that engine. Every +consumer — MCP step shells, the three memory services (Retriever, +Ingestor, Maintainer), and the agent toolkit — talks to the engine +through these functions, not by reaching into `BaseFileStore` directly. +That keeps `file_store` an implementation detail (could be local sqlite, +remote, etc.) and gives the layering one place to evolve. + +Four sections: + + 1. CRUD writes — write_create / delete / update / property_update + / rename / archive. The MFS write entry. + 2. MFS reads — read_file / list_files / links_of / backlinks_of + / wikilink_lookup / count_tokens / iter_files. + Primary-key lookups against the file_store cache. + 3. Projections — vector_search / keyword_search / expand_neighbors + / extract_anchors / chunks_by_paths / make_chunk_filter + / all_ambiguous_wikilinks. The read entry to derived + indexes (composed by Retriever into V+K+graph fusion). + 4. Toolkit — `MemoryIO` class wrapping the read/write helpers as + `agentscope.tool` callables for ReActAgent (Ingestor). + +Lives in `reme2/memory/` (not `reme2/mcp/`) so memory services and the +MCP transport layer can both consume it without forming an import cycle +through the transport layer. +""" + +from __future__ import annotations + +import json +import re +import shutil +from collections.abc import Iterable, Iterator +from pathlib import Path +from typing import Any + +import frontmatter +from agentscope.message import TextBlock +from agentscope.tool import Toolkit, ToolResponse + +from ..component.runtime_response import _to_jsonable +from ..schema import ChunkFilter, FileChunk, FileMetadata +from ..utils.wikilink import WIKILINK_RE + + +# =========================================================================== +# Section 1 — CRUD writes +# =========================================================================== +# +# Every mutation in the system funnels through these. Hot-write MCP shells +# (sync, topic_create, memory_*) call them directly; cold-write services +# (Ingestor R-M-W, Maintainer decay) compose them. + + +def _replace_wikilink_targets(text: str, mapping: dict[str, str]) -> str: + """Rewrite wikilink targets in raw text. + + Only the `target` portion of `[[target]]` / `[[target#anchor]]` / + `[[target|alias]]` / `![[target]]` is replaced; anchors, aliases, + and embed prefixes are preserved. + """ + if not mapping: + return text + + def sub(m: re.Match) -> str: + target_raw = m.group(1) + target = target_raw.strip() + if target in mapping: + return m.group(0).replace(target_raw, mapping[target], 1) + return m.group(0) + + return WIKILINK_RE.sub(sub, text) + + +def write_create( + file_store, + path: Path, + metadata: dict, + content: str, + overwrite: bool = False, + force: bool = False, +) -> tuple[bool, dict]: + """Single L1 entry point for creating a markdown file. + + All file creation in the project must funnel through this so the + wikilink uniqueness invariant is enforced in exactly one place. + + Refuses (returns (False, payload)) when: + - file already exists (unless overwrite=True) + - creating it would make `[[stem]]` resolve ambiguously against + the current file_store (unless force=True) + """ + if path.exists() and not overwrite: + return False, {"path": str(path), "error": "file already exists"} + + if not force: + conflicts = file_store.collisions_after_create(path) + if conflicts: + return False, { + "path": str(path), + "error": ( + f"stem `[[{path.stem}]]` would resolve ambiguously " + f"to {len(conflicts) + 1} paths after this create" + ), + "conflicts": conflicts, + "hint": ( + f"either rename to a unique stem, or have callers " + f"link via the explicit-path form " + f"`[[{path.parent.name}/{path.stem}]]`; pass " + f"force=true only if you accept the ambiguity" + ), + } + + path.parent.mkdir(parents=True, exist_ok=True) + post = frontmatter.Post(content, **metadata) + path.write_text(frontmatter.dumps(post), encoding="utf-8") + return True, {"path": str(path), "created": True} + + +def write_delete(path: Path | str) -> tuple[bool, dict]: + """Delete a file. Watcher removes from store + graph.""" + target = Path(path) + if not target.exists(): + return False, {"path": str(target), "error": "not found"} + target.unlink() + return True, {"path": str(target), "deleted": True} + + +def write_update( + path: Path | str, + old_string: str, + new_string: str, + replace_all: bool = False, +) -> tuple[bool, dict]: + """Edit-style content update — replace `old_string` with `new_string`.""" + target = Path(path) + if not target.is_file(): + return False, {"path": str(target), "error": "file not found"} + if not old_string: + return False, { + "path": str(target), + "error": "old_string is required (use write_create to write a new file)", + } + raw = target.read_text(encoding="utf-8") + occurrences = raw.count(old_string) + if occurrences == 0: + return False, {"path": str(target), "error": "old_string not found in file"} + if occurrences > 1 and not replace_all: + return False, { + "path": str(target), + "error": f"old_string appears {occurrences} times; pass replace_all=true to replace all", + "occurrences": occurrences, + } + if replace_all: + new_raw = raw.replace(old_string, new_string) + else: + new_raw = raw.replace(old_string, new_string, 1) + target.write_text(new_raw, encoding="utf-8") + return True, { + "path": str(target), + "replaced": occurrences if replace_all else 1, + } + + +def write_property_update(path: Path | str, key: str, value) -> tuple[bool, dict]: + """Update a single YAML frontmatter key. value=None deletes the key.""" + target = Path(path) + if not target.is_file(): + return False, {"path": str(target), "error": "file not found"} + raw = target.read_text(encoding="utf-8") + post = frontmatter.loads(raw) + if value is None: + post.metadata.pop(key, None) + else: + post.metadata[key] = value + target.write_text(frontmatter.dumps(post), encoding="utf-8") + return True, {"path": str(target), "key": key, "value": value} + + +def write_rename( + file_store, + vault_root: Path | str, + old_path: Path | str, + new_path: Path | str, +) -> tuple[bool, dict]: + """Rename a file and rewrite incoming wikilinks across the vault. + + Atomically moves `old_path` to `new_path`, then rewrites short-form + `[[old_stem]]` and path-form `[[old_relative]]` wikilinks in every + file that already had a *resolved* link to old_path. Look-up uses + `file_store.get_backlinks(old_path)` so the work is O(K) where K is + the number of incoming references — not a full vault scan. + + Refuses if: + - `old_path` doesn't exist + - `new_path` already exists + - the rename would make `[[new_stem]]` resolve ambiguously + """ + old_p = Path(old_path).resolve() + new_p = Path(new_path).resolve() + + if not old_p.is_file(): + return False, {"old_path": str(old_p), "error": "old_path not found"} + if new_p.exists(): + return False, {"new_path": str(new_p), "error": "new_path already exists"} + if old_p == new_p: + return False, {"error": "old_path and new_path are the same"} + + conflicts = file_store.collisions_after_create(new_p) + if conflicts: + return False, { + "error": ( + f"stem `[[{new_p.stem}]]` would resolve ambiguously " + f"to {len(conflicts) + 1} paths after this rename" + ), + "conflicts": conflicts, + "hint": ( + f"either rename to a unique stem (consider a " + f"domain-specific suffix), or have callers link via " + f"the explicit-path form `[[{new_p.parent.name}/{new_p.stem}]]`" + ), + } + + vault_root_p = Path(vault_root).resolve() + old_stem = old_p.stem + new_stem = new_p.stem + replacements: dict[str, str] = {} + if old_stem != new_stem: + replacements[old_stem] = new_stem + try: + old_rel = str(old_p.relative_to(vault_root_p).with_suffix("")) + new_rel = str(new_p.relative_to(vault_root_p).with_suffix("")) + if old_rel != new_rel: + replacements[old_rel] = new_rel + replacements[old_rel + ".md"] = new_rel + ".md" + except ValueError: + pass # paths outside vault root — skip path-form rewrite + + referring_paths = [m.path for m, _ in file_store.get_backlinks(str(old_p))] + + new_p.parent.mkdir(parents=True, exist_ok=True) + old_p.rename(new_p) + + updated_files: list[str] = [] + write_errors: list[dict] = [] + if replacements and referring_paths: + for path in referring_paths: + file_path = Path(path) + if not file_path.is_file(): + continue + try: + raw = file_path.read_text(encoding="utf-8") + new_raw = _replace_wikilink_targets(raw, replacements) + if new_raw != raw: + file_path.write_text(new_raw, encoding="utf-8") + updated_files.append(path) + except Exception as exc: + write_errors.append({"path": path, "error": str(exc)}) + + return True, { + "old_path": str(old_p), + "new_path": str(new_p), + "stem_changed": old_stem != new_stem, + "replacements": replacements, + "referring_count": len(referring_paths), + "updated_files": updated_files, + "write_errors": write_errors, + } + + +def write_archive( + vault_root: Path | str, + path: Path | str, + archive_dir_name: str = "Archive", +) -> tuple[bool, dict]: + """Archive a file: flip `status: archived`, then move under `//`. + + Composed by the Maintainer's decay pass when an event falls past its + freshness window. Backlinks are *not* rewritten — dangling links to + archived files are the intended audit trail. + """ + src = Path(path).resolve() + if not src.is_file(): + return False, {"path": str(src), "error": "file not found"} + + vault = Path(vault_root).resolve() + try: + rel = src.relative_to(vault) + except ValueError: + return False, { + "path": str(src), + "error": f"path is outside vault_root {vault}", + } + + dst = vault / archive_dir_name / rel + if dst.exists(): + return False, { + "path": str(src), + "error": f"archive destination already exists: {dst}", + } + + ok, prop_payload = write_property_update(src, "status", "archived") + if not ok: + return False, {**prop_payload, "stage": "property_update"} + + dst.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.move(str(src), str(dst)) + except OSError as exc: + return False, { + "path": str(src), + "error": f"move failed: {exc}", + "stage": "move", + } + return True, { + "old_path": str(src), + "new_path": str(dst), + "archived": True, + } + + +# =========================================================================== +# Section 2 — MFS reads (primary-key lookups against the file_store cache) +# =========================================================================== +# +# These don't touch the projection indexes — they hit the in-memory file +# meta + edge cache that the file_store maintains as a mirror of disk. +# For body / chunk content reads, `read_file` does fall through to the +# disk to get the latest text (file_store cache may lag a write). + + +async def read_file( + file_store, + path: str, + *, + include_chunks: bool = False, +) -> dict: + """Read frontmatter + body for one path. Optionally include parsed chunks. + + On-disk frontmatter is the source of truth — the file_store cache may + lag a write that hasn't been picked up by the watcher yet. + """ + meta = file_store.get_file_meta(path) + result: dict = {"path": path, "exists": False} + if meta is not None: + edges = file_store.get_edges(path) + result.update({ + "exists": True, + "metadata": meta.metadata, + "link": [e.model_dump(exclude_none=True) for e in edges], + }) + + file_path = Path(path) + if file_path.is_file(): + raw = file_path.read_text(encoding="utf-8") + post = frontmatter.loads(raw) + result["exists"] = True + result["content"] = post.content + result["metadata"] = dict(post.metadata) + + if include_chunks: + chunks = await file_store.get_chunks(path) + result["chunks"] = [c.model_dump(exclude_none=True) for c in chunks] + return result + + +def list_files( + file_store, + *, + path_prefix: str | None = None, + tags: list[str] | None = None, + metadata: dict | None = None, + limit: int = 100, +) -> dict: + """List indexed files filtered by frontmatter exact-match, tags, and prefix. + + Returns {items: [{path, metadata}], count}. + """ + metadata_filter = metadata or {} + tag_filter = tags or [] + items: list[dict] = [] + for path, meta in file_store.nodes.items(): + if path_prefix and not path.startswith(path_prefix): + continue + md = meta.metadata or {} + if metadata_filter and any(md.get(k) != v for k, v in metadata_filter.items()): + continue + if tag_filter: + file_tags = set(md.get("tags", []) or []) + if not all(t in file_tags for t in tag_filter): + continue + items.append({"path": path, "metadata": md}) + if len(items) >= limit: + break + return {"items": items, "count": len(items)} + + +def _edge_to_dict(file_meta, edge) -> dict: + return { + "path": file_meta.path, + "metadata": file_meta.metadata, + "predicate": edge.predicate, + "anchor": edge.anchor, + "alias": edge.alias, + "embed": edge.embed, + "source": edge.source, + "confidence": edge.confidence, + } + + +def links_of(file_store, path: str) -> dict: + """Files that `path` links TO (resolved). Each entry carries the typed-edge predicate.""" + return { + "path": path, + "links": [_edge_to_dict(m, e) for m, e in file_store.get_links(path)], + } + + +def backlinks_of(file_store, path: str) -> dict: + """Files that link TO `path`. Each entry carries the typed-edge predicate.""" + return { + "path": path, + "backlinks": [_edge_to_dict(m, e) for m, e in file_store.get_backlinks(path)], + } + + +def wikilink_lookup(file_store, wikilink: str) -> dict: + """Resolve a `[[target]]` wikilink with full ambiguity context. + + Distinct from `file_store.resolve_wikilink(target)` (which returns + the single resolved path or None) — this surface returns the rich + payload callers need to disambiguate: + + unique resolution → {wikilink, path, exists: True, + ambiguous: False, candidates: [path]} + ambiguous → {wikilink, path: None, exists: False, + ambiguous: True, candidates: [...]} + dangling → {wikilink, path: None, exists: False, + ambiguous: False, candidates: []} + """ + # Path-form (`a/b` or `a/b.md`): file_store already returns + # exactly the one path that exists, or None. + if "/" in wikilink or wikilink.endswith(".md"): + hit = file_store.resolve_wikilink(wikilink) + return { + "wikilink": wikilink, + "path": hit, + "exists": hit is not None, + "ambiguous": False, + "candidates": [hit] if hit else [], + } + + # Stem-form: candidates list reveals 0/1/N resolution. + candidates = file_store.wikilink_candidates(wikilink) + if len(candidates) == 1: + return { + "wikilink": wikilink, + "path": candidates[0], + "exists": True, + "ambiguous": False, + "candidates": candidates, + } + return { + "wikilink": wikilink, + "path": None, + "exists": False, + "ambiguous": len(candidates) > 1, + "candidates": candidates, + } + + +async def count_tokens( + token_counter, + *, + path: str | None = None, + text: str | None = None, +) -> dict: + """Estimate tokens for a file body (frontmatter excluded) or raw text. + + Powers the Maintainer's split-trigger. Exactly one of `path` / `text` + must be provided. + """ + if path: + target = Path(path) + if not target.is_file(): + return {"path": str(target), "error": "file not found"} + raw = target.read_text(encoding="utf-8") + post = frontmatter.loads(raw) + body = post.content + tokens = await token_counter.count(messages=[], text=body) + return { + "source": "file", + "path": str(target.resolve()), + "tokens": tokens, + "body_chars": len(body), + } + if text: + tokens = await token_counter.count(messages=[], text=text) + return { + "source": "text", + "tokens": tokens, + "body_chars": len(text), + } + return {"error": "one of `path` or `text` is required"} + + +def iter_files(file_store) -> Iterator[tuple[str, FileMetadata]]: + """Walk every indexed (path, FileMetadata). Used by Maintainer scans. + + Equivalent to `file_store.nodes.items()`, exposed here so consumers + don't have to know about the underlying cache attribute name. + """ + return iter(file_store.nodes.items()) + + +# =========================================================================== +# Section 3 — Projection queries (Vector / FTS / File Graph) +# =========================================================================== +# +# Per `structure.md` §"核心引擎", the Vector index, FTS5 index, and File +# Graph are downstream **projections** of the MFS — they can be wholly +# rebuilt from disk. These functions are the read entry to those +# projections; `Retriever` composes them with policy (V+K weighting, +# graph BFS, intent routing) for ranked retrieval. + + +async def vector_search( + file_store, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, +) -> list[FileChunk]: + """Vector similarity over the chunk-level Vector projection.""" + return await file_store.vector_search(query, limit, chunk_filter) + + +async def keyword_search( + file_store, + query: str, + limit: int, + chunk_filter: ChunkFilter | None = None, +) -> list[FileChunk]: + """FTS5 keyword search over the chunk-level keyword projection.""" + return await file_store.keyword_search(query, limit, chunk_filter) + + +def expand_neighbors( + file_store, + seeds: Iterable[str], + *, + depth: int = 1, + direction: str = "both", +) -> dict[str, int]: + """BFS over the File Graph projection. Returns {path: hop_distance}.""" + return file_store.expand_neighbors(seeds, depth=depth, direction=direction) + + +def extract_anchors(file_store, text: str) -> list[str]: + """Pull anchor paths from `[[target]]` references inside `text`.""" + return file_store.extract_anchor_paths(text) + + +async def chunks_by_paths(file_store, paths: Iterable[str]) -> list[FileChunk]: + """Batch fetch chunks across many paths (used by graph-walk retrieval).""" + return await file_store.get_chunks_by_paths(paths) + + +def make_chunk_filter( + file_store, + *, + paths: list[str] | None = None, + tags: list[str] | None = None, + exclude_paths: list[str] | None = None, +) -> ChunkFilter | None: + """Build a chunk-filter against the file_store's path/tag indexes.""" + return file_store.filter(paths=paths, tags=tags, exclude_paths=exclude_paths) + + +def all_ambiguous_wikilinks(file_store) -> dict[str, list[str]]: + """Every stem that resolves to >1 path. Used by Maintainer.lint.""" + return file_store.all_ambiguous_wikilinks() + + +# =========================================================================== +# Section 4 — Agent toolkit +# =========================================================================== +# +# `MemoryIO` adapts the read/write helpers above as `agentscope.tool` +# callables, with a vault-root containment check on writes and an audit +# trail every consumer can inspect after the agent's run. Used by the +# Ingestor's ReActAgent. + + +def _text_response(payload: Any) -> ToolResponse: + text = json.dumps(_to_jsonable(payload), ensure_ascii=False, indent=2) + return ToolResponse(content=[TextBlock(type="text", text=text)]) + + +class MemoryIO: + """Agent-facing tool surface over the Memory File System. + + Args: + file_store: The vault's FileStore (provides graph + index). + vault_root: Containment boundary — write tools refuse paths + that escape it. + + Attributes: + audit: Every successful or failed write is appended here. Read + this after the agent's run to reconstruct the mutation trail. + """ + + _TOOL_NAMES = ( + "memory_get", + "memory_list", + "memory_resolve_wikilink", + "memory_backlinks", + "memory_links", + "memory_create", + "memory_update", + "memory_property_update", + "memory_rename", + "memory_delete", + "memory_archive", + ) + + def __init__(self, file_store, vault_root: str | Path): + self.file_store = file_store + self.vault_root = Path(vault_root).resolve() + self.audit: list[dict] = [] + + def register_all(self, toolkit: Toolkit | None = None) -> Toolkit: + """Register every memory_* method on `toolkit` (or a fresh one).""" + toolkit = toolkit or Toolkit() + for name in self._TOOL_NAMES: + toolkit.register_tool_function( + getattr(self, name), namesake_strategy="override", + ) + return toolkit + + # -- Internals -------------------------------------------------------- + + def _resolve(self, p: str) -> Path: + pp = Path(p) + if not pp.is_absolute(): + pp = self.vault_root / pp + return pp.resolve() + + def _under_vault(self, p: Path) -> bool: + try: + p.relative_to(self.vault_root) + return True + except (ValueError, OSError): + return False + + def _record(self, op: str, ok: bool, **fields) -> dict: + entry = {"op": op, "ok": ok, **fields} + self.audit.append(entry) + return entry + + # -- Read tools (delegate to module-level helpers) -------------------- + + async def memory_get(self, path: str, include_chunks: bool = False) -> ToolResponse: + """Read a memory file (frontmatter + body, optional chunks). + + Args: + path (str): Absolute path to the file. + include_chunks (bool): Include parsed chunk metadata. + """ + target = self._resolve(path) + result = await read_file(self.file_store, str(target), include_chunks=include_chunks) + return _text_response(result) + + async def memory_list( + self, + path_prefix: str | None = None, + tags: list[str] | None = None, + metadata: dict | None = None, + limit: int = 100, + ) -> ToolResponse: + """List indexed vault files filtered by prefix, tags, and frontmatter. + + Args: + path_prefix (str | None): Restrict to paths starting with this prefix. + tags (list[str] | None): All tags must be present on a file. + metadata (dict | None): Exact-match filter on frontmatter keys. + limit (int): Cap on returned items. + """ + return _text_response(list_files( + self.file_store, + path_prefix=path_prefix, tags=tags, + metadata=metadata, limit=limit, + )) + + async def memory_resolve_wikilink(self, wikilink: str) -> ToolResponse: + """Resolve a `[[wikilink]]` to an absolute path. + + Args: + wikilink (str): The wikilink target, e.g. `Topic` or `topics/Topic`. + """ + return _text_response(wikilink_lookup(self.file_store, wikilink)) + + async def memory_backlinks(self, path: str) -> ToolResponse: + """List files linking TO a given path. + + Args: + path (str): Absolute path to inspect. + """ + target = self._resolve(path) + return _text_response(backlinks_of(self.file_store, str(target))) + + async def memory_links(self, path: str) -> ToolResponse: + """List files a given path links to. + + Args: + path (str): Absolute path to inspect. + """ + target = self._resolve(path) + return _text_response(links_of(self.file_store, str(target))) + + # -- Write tools (delegate to write_*; record audit) ------------------ + + async def memory_create( + self, + path: str, + metadata: dict | None = None, + content: str = "", + overwrite: bool = False, + force: bool = False, + ) -> ToolResponse: + """Create a new markdown file in the vault. + + Args: + path (str): Target path (resolved against vault_root if relative). + metadata (dict | None): YAML frontmatter for the new file. + content (str): Body markdown. + overwrite (bool): Allow overwriting an existing file. + force (bool): Allow creates that introduce stem-form wikilink ambiguity. + """ + target = self._resolve(path) + if not self._under_vault(target): + entry = self._record("create", False, path=str(target), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_create( + self.file_store, target, + metadata=dict(metadata or {}), content=content, + overwrite=overwrite, force=force, + ) + entry = self._record("create", ok, path=str(target), result=payload) + return _text_response(entry) + + async def memory_update( + self, + path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + ) -> ToolResponse: + """Edit a file body by exact-string substitution. + + Use a unique snippet for `old_string`. To append, pass the file's + tail as `old_string` and `tail + new_content` as `new_string`. + + Args: + path (str): Absolute path to the file. + old_string (str): Exact text to replace (must be unique unless replace_all). + new_string (str): Replacement text. + replace_all (bool): Replace every occurrence instead of just one. + """ + target = self._resolve(path) + if not self._under_vault(target): + entry = self._record("update", False, path=str(target), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_update(target, old_string, new_string, replace_all=replace_all) + entry = self._record("update", ok, path=str(target), result=payload) + return _text_response(entry) + + async def memory_property_update(self, path: str, key: str, value: Any = None) -> ToolResponse: + """Update one YAML frontmatter key on a file (value=null deletes it). + + Args: + path (str): Absolute path to the file. + key (str): Frontmatter key. + value: New value, or null to delete the key. + """ + target = self._resolve(path) + if not self._under_vault(target): + entry = self._record("property_update", False, path=str(target), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_property_update(target, key, value) + entry = self._record("property_update", ok, path=str(target), result=payload) + return _text_response(entry) + + async def memory_rename(self, old_path: str, new_path: str) -> ToolResponse: + """Rename a file and rewrite cross-vault wikilinks. + + Args: + old_path (str): Current absolute path. + new_path (str): Target absolute path. + """ + old_p = self._resolve(old_path) + new_p = self._resolve(new_path) + if not self._under_vault(old_p) or not self._under_vault(new_p): + entry = self._record("rename", False, old_path=str(old_p), new_path=str(new_p), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_rename(self.file_store, self.vault_root, old_p, new_p) + entry = self._record("rename", ok, old_path=str(old_p), new_path=str(new_p), result=payload) + return _text_response(entry) + + async def memory_delete(self, path: str) -> ToolResponse: + """Delete a file from the vault. + + Args: + path (str): Absolute path to the file. + """ + target = self._resolve(path) + if not self._under_vault(target): + entry = self._record("delete", False, path=str(target), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_delete(target) + entry = self._record("delete", ok, path=str(target), result=payload) + return _text_response(entry) + + async def memory_archive(self, path: str, archive_dir: str = "Archive") -> ToolResponse: + """Flip `status: archived` and move file under `//`. + + Args: + path (str): Absolute path to the file. + archive_dir (str): Subdirectory name under vault_root for archives. + """ + target = self._resolve(path) + if not self._under_vault(target): + entry = self._record("archive", False, path=str(target), + error=f"path is outside vault_root {self.vault_root}") + return _text_response(entry) + ok, payload = write_archive(self.vault_root, target, archive_dir) + entry = self._record("archive", ok, path=str(target), result=payload) + return _text_response(entry) diff --git a/reme2/memory/retriever.py b/reme2/memory/retriever.py new file mode 100644 index 00000000..4641ea80 --- /dev/null +++ b/reme2/memory/retriever.py @@ -0,0 +1,344 @@ +"""Retriever — read service. The Memory subsystem's read-side facade. + +Per the architecture blueprint, retrieval policy lives here (not in the +file_store): query understanding (intent routing) + multi-channel +fusion (V + K + graph BFS) + ranking. The file_store exposes only +single-channel index primitives; this module composes them with +strategy. + +Two surfaces: + + `search(query, ...)` — pure relevance retrieval (V + K hybrid). + `graph_search(query, ...)` — V + K + graph fusion (context expansion + through wikilinks). + +`BaseRetriever` inherits `BaseStep` so concrete retrievers get the +standard Step lifecycle + `self.file_store` property + per-instance +configuration via constructor kwargs. The default `execute()` dispatch +just runs `graph_search` so the retriever can also be used directly as +a step inside a job pipeline; the MCP step shells in +`reme2.mcp.steps.memory_retriever` instead bypass `execute` and call +`search` / `graph_search` directly so they own the result-serialization +shape. +""" + +from __future__ import annotations + +import asyncio +from abc import abstractmethod +from collections import defaultdict + +from ..component import R +from ..component.base_step import BaseStep +from ..schema import ChunkFilter, FileChunk +from . import memory_io + + +class BaseRetriever(BaseStep): + """Pluggable retrieval strategy. + + Inherits `BaseStep`, so: + - `component_type = ComponentEnum.STEP` (for registry lookup). + - `self.file_store` resolves the configured file_store via the + standard kwargs / app_context route — no manual `_start` lookup. + - kwargs (`vector_weight`, `graph_weight`, …) flow through the + standard Step kwargs handshake. + + Concrete subclasses implement `search` and `graph_search`. + """ + + @abstractmethod + async def search( + self, + query: str, + *, + max_results: int = 5, + min_score: float = 0.0, + chunk_filter: ChunkFilter | None = None, + ) -> list[FileChunk]: + """Pure-relevance retrieval (V + K hybrid by convention).""" + + @abstractmethod + async def graph_search( + self, + query: str = "", + *, + seeds: list[str] | None = None, + max_results: int = 5, + min_score: float = 0.0, + chunk_filter: ChunkFilter | None = None, + # Per-call overrides; None = use this retriever's own defaults. + vector_weight: float | None = None, + graph_weight: float | None = None, + graph_depth: int | None = None, + graph_decay: float | None = None, + graph_direction: str | None = None, + graph_mode: str | None = None, + graph_per_path_cap: int | None = None, + anchor_expand: bool | None = None, + ) -> tuple[list[FileChunk], dict[str, int]]: + """Three-way fusion search (V + K + graph BFS over wikilinks). + + Returns: + (chunks, hops) — `hops[path]` is the BFS distance from the + seed set to that path (0 = seed, 1 = 1-hop neighbor, …). + """ + + async def execute(self): + """Default Step entry point — dispatches to `graph_search`. + + The retriever is normally invoked via `search` / `graph_search` + directly by the MCP step shells, but this lets the retriever + also be wired as a step inside a job pipeline (e.g. for + debugging / scripts). Reads RuntimeContext for the standard + query / max_results / min_score / seeds + filter args. + """ + assert self.context is not None + ctx = self.context + chunk_filter = memory_io.make_chunk_filter( + self.file_store, + paths=ctx.get("paths") or None, + tags=ctx.get("tags") or None, + exclude_paths=ctx.get("exclude_paths") or None, + ) + results, _hops = await self.graph_search( + query=ctx.get("query", "").strip(), + seeds=list(ctx.get("seeds") or []), + max_results=int(ctx.get("max_results", 5)), + min_score=float(ctx.get("min_score", 0.0)), + chunk_filter=chunk_filter, + ) + ctx.response.success = True + ctx.response.answer = [c.model_dump(exclude_none=True, exclude={"embedding"}) for c in results] + + +@R.register("hybrid") +class HybridRetriever(BaseRetriever): + """V + K (+ optional graph BFS) fusion retriever. + + Composes the file_store's single-channel primitives (`vector_search`, + `keyword_search`, `expand_neighbors`, `extract_anchor_paths`, + `get_chunks_by_paths`). Knobs (`vector_weight`, `graph_weight`, + `graph_depth`, `graph_decay`, `graph_direction`, `graph_mode`, + `graph_per_path_cap`, `anchor_expand`, `candidate_multiplier`) are + constructor defaults; `graph_search` accepts per-call overrides for + the graph fusion knobs (any None → fall back to constructor default). + """ + + def __init__( + self, + vector_weight: float = 0.7, + graph_weight: float = 0.3, + graph_depth: int = 1, + graph_decay: float = 0.5, + graph_direction: str = "both", + graph_mode: str = "additive", + graph_per_path_cap: int = 3, + candidate_multiplier: float = 3.0, + anchor_expand: bool = True, + **kwargs, + ): + super().__init__(**kwargs) + if graph_mode not in ("additive", "boost"): + raise ValueError(f"graph_mode must be 'additive' or 'boost', got {graph_mode!r}") + self.vector_weight = vector_weight + self.graph_weight = graph_weight + self.graph_depth = graph_depth + self.graph_decay = graph_decay + self.graph_direction = graph_direction + self.graph_mode = graph_mode + self.graph_per_path_cap = graph_per_path_cap + self.candidate_multiplier = candidate_multiplier + self.anchor_expand = anchor_expand + + async def search( + self, + query: str, + *, + max_results: int = 5, + min_score: float = 0.0, + chunk_filter: ChunkFilter | None = None, + ) -> list[FileChunk]: + """V + K hybrid retrieval (no graph). Owns the fusion policy. + + Pipeline: + 1. If both channels are enabled → run V + K in parallel, + merge by unique_key with weighted score. + 2. If only one is enabled → return that channel's results + directly (no fusion to do). + 3. If neither → empty. + """ + fs = self.file_store + candidates = min(200, max(1, int(max_results * self.candidate_multiplier))) + text_weight = 1.0 - self.vector_weight + + if fs.vector_enabled and fs.fts_enabled: + v_task = memory_io.vector_search(fs, query, candidates, chunk_filter) + k_task = memory_io.keyword_search(fs, query, candidates, chunk_filter) + v_results, k_results = await asyncio.gather(v_task, k_task) + + if not k_results: + results = v_results[:max_results] + elif not v_results: + results = k_results[:max_results] + else: + results = self._merge_vk( + v_results, k_results, self.vector_weight, text_weight, + )[:max_results] + elif fs.vector_enabled: + results = await memory_io.vector_search(fs, query, max_results, chunk_filter) + elif fs.fts_enabled: + results = await memory_io.keyword_search(fs, query, max_results, chunk_filter) + else: + results = [] + + if min_score > 0: + results = [r for r in results if r.score >= min_score] + return results + + @staticmethod + def _merge_vk( + vector: list[FileChunk], + keyword: list[FileChunk], + vector_weight: float, + text_weight: float, + ) -> list[FileChunk]: + """Weighted V+K merge by unique_key. Vector first (canonical), + keyword adds its weighted subscore on collision or seeds fresh.""" + merged: dict[str, FileChunk] = {} + for r in vector: + r.scores["score"] = r.scores.get("vector", 0.0) * vector_weight + merged[r.unique_key] = r + for r in keyword: + key = r.unique_key + k = r.scores.get("keyword", 0.0) + if key in merged: + merged[key].scores["score"] += k * text_weight + else: + r.scores["score"] = k * text_weight + merged[key] = r + results = list(merged.values()) + results.sort(key=lambda c: c.score, reverse=True) + return results + + async def graph_search( + self, + query: str = "", + *, + seeds: list[str] | None = None, + max_results: int = 5, + min_score: float = 0.0, + chunk_filter: ChunkFilter | None = None, + vector_weight: float | None = None, + graph_weight: float | None = None, + graph_depth: int | None = None, + graph_decay: float | None = None, + graph_direction: str | None = None, + graph_mode: str | None = None, + graph_per_path_cap: int | None = None, + anchor_expand: bool | None = None, + ) -> tuple[list[FileChunk], dict[str, int]]: + # Resolve per-call overrides → constructor defaults. + vw = self.vector_weight if vector_weight is None else float(vector_weight) + gw = self.graph_weight if graph_weight is None else float(graph_weight) + gd = self.graph_depth if graph_depth is None else int(graph_depth) + gdc = self.graph_decay if graph_decay is None else float(graph_decay) + gdr = self.graph_direction if graph_direction is None else graph_direction + gm = self.graph_mode if graph_mode is None else graph_mode + gpc = self.graph_per_path_cap if graph_per_path_cap is None else int(graph_per_path_cap) + ae = self.anchor_expand if anchor_expand is None else bool(anchor_expand) + + if gm not in ("additive", "boost"): + raise ValueError(f"graph_mode must be 'additive' or 'boost', got {gm!r}") + if not (0.0 <= vw <= 1.0): + raise ValueError(f"vector_weight must be in [0,1], got {vw}") + if not (0.0 <= gw <= 1.0): + raise ValueError(f"graph_weight must be in [0,1], got {gw}") + + explicit_seeds = list(seeds or []) + if not query and not explicit_seeds: + raise ValueError("graph_search: query or seeds must be provided") + + candidate_count = max(max_results, int(max_results * self.candidate_multiplier)) + fs = self.file_store + + # 1. V + K in parallel (each is a no-op when its backend is disabled). + if query: + v_task = memory_io.vector_search(fs, query, candidate_count, chunk_filter) + k_task = memory_io.keyword_search(fs, query, candidate_count, chunk_filter) + v_results, k_results = await asyncio.gather(v_task, k_task) + else: + v_results, k_results = [], [] + + # 2. Build seed set. + seed_paths: set[str] = set() + for c in v_results: + seed_paths.add(c.path) + for c in k_results: + seed_paths.add(c.path) + for p in explicit_seeds: + if p in fs: + seed_paths.add(p) + if ae and query: + seed_paths.update(memory_io.extract_anchors(fs, query)) + + # 3. Graph expansion. + if gw > 0 and seed_paths and gd >= 0: + hops = memory_io.expand_neighbors(fs, seed_paths, depth=gd, direction=gdr) + else: + hops = {} + graph_scores: dict[str, float] = {p: gdc ** h for p, h in hops.items()} + + # 4. Pull graph-only chunks (paths in expansion but not in V∪K). + # Skip in 'boost' mode — boost only re-ranks V∪K, never adds candidates. + vk_paths = {c.path for c in v_results} | {c.path for c in k_results} + if gm == "additive" and hops: + extra_paths = set(hops) - vk_paths + if chunk_filter is not None and chunk_filter.resolved_paths is not None: + extra_paths &= chunk_filter.resolved_paths + extra_chunks = await memory_io.chunks_by_paths(fs, extra_paths) + # Per-path cap so a hub topic with N chunks doesn't flood results. + by_path: dict[str, list] = defaultdict(list) + for c in extra_chunks: + by_path[c.path].append(c) + graph_only_chunks: list[FileChunk] = [] + for path_chunks in by_path.values(): + path_chunks.sort(key=lambda c: c.start_line) + graph_only_chunks.extend(path_chunks[:gpc]) + else: + graph_only_chunks = [] + + # 5. Merge by unique_key. Vector first (canonical), keyword fills its + # subscore on existing entries, graph-only chunks come in fresh. + pool: dict = {} + for c in v_results: + pool[c.unique_key] = c + for c in k_results: + existing = pool.get(c.unique_key) + if existing is None: + pool[c.unique_key] = c + else: + existing.scores["keyword"] = c.scores.get("keyword", 0.0) + for c in graph_only_chunks: + pool.setdefault(c.unique_key, c) + + # 6. Final scoring. + for c in pool.values(): + v = c.scores.get("vector", 0.0) + k = c.scores.get("keyword", 0.0) + g = graph_scores.get(c.path, 0.0) + vk = vw * v + (1.0 - vw) * k + if gm == "boost": + final = vk * (1.0 + gw * g) + else: + final = (1.0 - gw) * vk + gw * g + c.scores["graph"] = g + c.scores["score"] = final + + # 7. Rank + filter + slice. + results = sorted(pool.values(), key=lambda c: c.score, reverse=True) + if min_score > 0: + results = [r for r in results if r.score >= min_score] + results = results[:max_results] + + return results, hops diff --git a/reme2/file_based/summarizer.py b/reme2/memory/summarizer.py similarity index 95% rename from reme2/file_based/summarizer.py rename to reme2/memory/summarizer.py index c5ba5f21..62335c9b 100644 --- a/reme2/file_based/summarizer.py +++ b/reme2/memory/summarizer.py @@ -18,16 +18,16 @@ class Summarizer(BaseStep): """Summarizer step for summarizing memory messages.""" def __init__( - self, - working_dir: str, - memory_dir: str, - memory_compact_threshold: int, - toolkit: Toolkit | None = None, - console_enabled: bool = False, - timezone: str | None = None, - add_thinking_block: bool = True, - as_token_counter: HuggingFaceTokenCounter | None = None, - **kwargs, + self, + working_dir: str, + memory_dir: str, + memory_compact_threshold: int, + toolkit: Toolkit | None = None, + console_enabled: bool = False, + timezone: str | None = None, + add_thinking_block: bool = True, + as_token_counter: HuggingFaceTokenCounter | None = None, + **kwargs, ): """Initialize the summarizer step. @@ -227,10 +227,10 @@ class Summarizer(BaseStep): return total async def _format_msgs_to_str( - self, - messages: list[Msg], - memory_compact_threshold: int, - include_thinking: bool = True, + self, + messages: list[Msg], + memory_compact_threshold: int, + include_thinking: bool = True, ) -> str: """Format list of messages to a single formatted string. diff --git a/reme2/file_based/summarizer.yaml b/reme2/memory/summarizer.yaml similarity index 99% rename from reme2/file_based/summarizer.yaml rename to reme2/memory/summarizer.yaml index a9cb59e6..7b448df6 100644 --- a/reme2/file_based/summarizer.yaml +++ b/reme2/memory/summarizer.yaml @@ -1,14 +1,14 @@ user_message: | Memory Pre-compression Flush Cycle. - + The current session is about to enter the automatic compression phase. Please capture persistent memory AND session reflections, then write them to disk. - + Current date: {date} Working directory: {working_dir} - + # Task Immediately store persistent memory and reflections to: {memory_dir}/YYYY-MM-DD.md - + # Workflow 1. Extract and synthesize content from the current session: - Persistent Memory: Facts, user profile updates, project states, and important events. @@ -17,7 +17,7 @@ user_message: | - If the file doesn’t exist, use `write` tool directly. - If the file exists, intelligently merge new information with existing content, prefer using `edit` to update specific sections. - Use `write` to overwrite the entire file only if substantial restructuring is required. - + # Principles - Intelligently merge new information with existing content: - Categorize clearly (e.g., separate "Factual Memory" from "Reflections & Logic"). @@ -32,15 +32,15 @@ user_message: | user_message_zh: | 预压缩内存刷新轮次。 - + 当前会话即将进入自动压缩阶段;请将持久化记忆与经验反思捕获并写入磁盘。 - + 当前日期:{date} 工作目录:{working_dir} - + # 任务 立即存储持久化记忆与反思(使用路径 {memory_dir}/YYYY-MM-DD.md)。 - + # 工作流程 1. 从当前会话中提取并综合两类内容: - 持久化记忆:客观事实、用户信息更新、项目状态及重要事件。 @@ -48,7 +48,7 @@ user_message_zh: | 2. `read` {memory_dir}/YYYY-MM-DD.md(如文件不存在,会返回错误提示) - 若文件不存在,直接使用 `write` 工具写入。 - 若文件已存在,智能合并新信息与现有内容,尽可能使用 `edit` 更新特定部分,仅在需要大幅重构时使用 `write` 覆盖整个文件。 - + # 原则 - 智能合并新信息与现有内容: - 将内容进行清晰的分类(例如明确区分“事实记忆”与“反思与逻辑”)。 diff --git a/reme2/reme_backup.py b/reme2/reme_backup.py index 99fd369f..16ba6a4e 100644 --- a/reme2/reme_backup.py +++ b/reme2/reme_backup.py @@ -13,7 +13,7 @@ from .application import Application from .component import R, RuntimeContext from .config import parse_args from .enumeration import ComponentEnum -from .file_based.summarizer import Summarizer +from .memory.summarizer import Summarizer from .utils import run_coro_safely @@ -21,17 +21,17 @@ class ReMe(Application): """ReMe memory management application.""" async def summarize( - self, - messages: list[Msg], - as_llm: str | ChatModelBase = "default", - as_llm_formatter: str | FormatterBase = "default", - as_token_counter: str | TokenCounterBase | HuggingFaceTokenCounter = "default", - toolkit: Toolkit | None = None, - language: str = "zh", - max_input_length: float = 128 * 1024, - compact_ratio: float = 0.7, - timezone: str | None = None, - add_thinking_block: bool = True, + self, + messages: list[Msg], + as_llm: str | ChatModelBase = "default", + as_llm_formatter: str | FormatterBase = "default", + as_token_counter: str | TokenCounterBase | HuggingFaceTokenCounter = "default", + toolkit: Toolkit | None = None, + language: str = "zh", + max_input_length: float = 128 * 1024, + compact_ratio: float = 0.7, + timezone: str | None = None, + add_thinking_block: bool = True, ) -> str: """Summarize and compact memory messages. @@ -84,7 +84,7 @@ class ReMe(Application): async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> str: """Search memory for relevant entries.""" - from .file_based.memory_search import MemorySearch + from .memory.memory_retriever import MemorySearch try: search_step = MemorySearch() @@ -94,25 +94,25 @@ class ReMe(Application): return str(e) async def dream( - self, - as_llm: str | ChatModelBase = "default", - as_llm_formatter: str | FormatterBase = "default", - as_token_counter: str | TokenCounterBase = "default", - toolkit: Toolkit | None = None, - language: str = "zh", - timezone: str | None = None, + self, + as_llm: str | ChatModelBase = "default", + as_llm_formatter: str | FormatterBase = "default", + as_token_counter: str | TokenCounterBase = "default", + toolkit: Toolkit | None = None, + language: str = "zh", + timezone: str | None = None, ) -> str: """Process and consolidate memories in background.""" return "" async def proactive( - self, - as_llm: str | ChatModelBase = "default", - as_llm_formatter: str | FormatterBase = "default", - as_token_counter: str | TokenCounterBase = "default", - toolkit: Toolkit | None = None, - language: str = "zh", - timezone: str | None = None, + self, + as_llm: str | ChatModelBase = "default", + as_llm_formatter: str | FormatterBase = "default", + as_token_counter: str | TokenCounterBase = "default", + toolkit: Toolkit | None = None, + language: str = "zh", + timezone: str | None = None, ) -> str: """Generate proactive memory insights.""" return "" diff --git a/reme2/schema/__init__.py b/reme2/schema/__init__.py index b0c9310f..37751258 100644 --- a/reme2/schema/__init__.py +++ b/reme2/schema/__init__.py @@ -5,7 +5,9 @@ from .as_msg_stat import AsBlockStat, AsMsgStat from .base_node import BaseNode from .chunk_filter import ChunkFilter from .file_chunk import FileChunk +from .file_edge import FileEdge from .file_metadata import FileMetadata +from .parsed_file import ParsedFile from .request import Request from .response import Response from .stream_chunk import StreamChunk @@ -19,7 +21,9 @@ __all__ = [ "BaseNode", "ChunkFilter", "FileChunk", + "FileEdge", "FileMetadata", + "ParsedFile", "Request", "Response", "StreamChunk", diff --git a/reme2/schema/chunk_filter.py b/reme2/schema/chunk_filter.py index 9cd4870f..b2b87b93 100644 --- a/reme2/schema/chunk_filter.py +++ b/reme2/schema/chunk_filter.py @@ -1,23 +1,23 @@ -"""Filter for chunk-store search. +"""Filter for chunk search. User-facing fields (paths/tags/exclude_paths) describe metadata-level intent. -FileGraph compiles them into `resolved_paths` (the concrete path set that -ChunkStore actually consumes for filtering). +file_store compiles them into `resolved_paths` (the concrete path set that +the chunk search actually consumes for filtering). """ from pydantic import BaseModel, Field class ChunkFilter(BaseModel): - """User-facing search filter, resolved by FileGraph into a path set. + """User-facing search filter, resolved by file_store into a path set. User input fields: paths: include only paths starting with any of these prefixes tags: include only files whose metadata contains ALL these tags exclude_paths: exclude paths starting with any of these prefixes - Compiled field (set by FileGraph.filter): - resolved_paths: concrete path set to restrict ChunkStore search. + Compiled field (set by file_store.filter): + resolved_paths: concrete path set to restrict chunk search. None = no restriction (all chunks). Empty set = no chunks match (search returns empty). """ @@ -43,7 +43,7 @@ class ChunkFilter(BaseModel): return True def match_path(self, path: str) -> bool: - """Match a path against the resolved path set (used by ChunkStore).""" + """Match a path against the resolved path set (used by FileStore).""" if self.resolved_paths is None: return True return path in self.resolved_paths diff --git a/reme2/schema/file_edge.py b/reme2/schema/file_edge.py new file mode 100644 index 00000000..b1b68deb --- /dev/null +++ b/reme2/schema/file_edge.py @@ -0,0 +1,43 @@ +"""FileEdge — typed wikilink edge between vault files. + +Replaces the prior `link: list[str]` model on FileMetadata. An edge +captures both the bare wikilink shape (`target` + optional anchor / +alias / embed prefix) AND the typed-edge predicate that turns a plain +`[[X]]` reference into a graph relation. + +Sources: + "regex" — extracted from inline syntax (`[[X]]`, `[pred:: [[X]]]`) + "frontmatter" — inferred from a frontmatter key acting as predicate + (e.g. `author: "[[John]]"` → predicate="author") + "llm" — produced by an IE pipeline; should set `confidence` + +The `target` field stays raw (e.g. `"X"` or `"topics/X"`) — resolution +to an absolute path is done by the file_store via `resolve_wikilink`. +""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class FileEdge(BaseModel): + target: str = Field(..., description="Raw wikilink target as written in source.") + predicate: str | None = Field( + default=None, + description="Typed-edge predicate (Dataview-style). None for plain wikilinks.", + ) + anchor: str | None = Field(default=None, description="Heading or block anchor (after #).") + alias: str | None = Field(default=None, description="Display alias (after |).") + embed: bool = Field(default=False, description="True for `![[X]]` embed prefix.") + source: Literal["regex", "frontmatter", "llm"] = Field( + default="regex", + description="Provenance of this edge.", + ) + confidence: float | None = Field( + default=None, + description="LLM confidence (0..1). None for regex/frontmatter.", + ) + + @property + def is_typed(self) -> bool: + return self.predicate is not None diff --git a/reme2/schema/file_graph.py b/reme2/schema/file_graph.py deleted file mode 100644 index 0dc7a65c..00000000 --- a/reme2/schema/file_graph.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -from collections import defaultdict -from pathlib import Path - -from .chunk_filter import ChunkFilter -from .file_metadata import FileMetadata - - -class FileGraph: - - def __init__(self) -> None: - self._nodes: dict[str, FileMetadata] = {} - self._backlinks: dict[str, set[str]] = defaultdict(set) - - # -- CRUD ---------------------------------------------------------------- - - def create(self, *metadatas: FileMetadata) -> None: - for metadata in metadatas: - path = metadata.path - if path in self._nodes: - self._remove_forward(self._nodes[path]) - self._nodes[path] = metadata - for metadata in metadatas: - self._add_forward(metadata) - - def read(self, path: str) -> FileMetadata | None: - return self._nodes.get(path) - - def update(self, path: str, **fields) -> FileMetadata | None: - metadata = self._nodes.get(path) - if metadata is None: - return None - self._remove_forward(metadata) - updated = metadata.model_copy(update=fields) - self._nodes[path] = updated - self._add_forward(updated) - return updated - - def delete(self, path: str) -> FileMetadata | None: - metadata = self._nodes.pop(path, None) - if metadata is None: - return None - self._remove_forward(metadata) - self._backlinks.pop(path, None) - return metadata - - # -- Link queries -------------------------------------------------------- - - def get_links(self, path: str) -> list[FileMetadata]: - metadata = self._nodes.get(path) - if metadata is None: - return [] - return [self._nodes[link] for link in metadata.link if link in self._nodes] - - def get_backlinks(self, path: str) -> list[FileMetadata]: - return [ - self._nodes[src] - for src in self._backlinks.get(path, set()) - if src in self._nodes - ] - - def filter( - self, - paths: list[str] | None = None, - tags: list[str] | None = None, - exclude_paths: list[str] | None = None, - ) -> ChunkFilter: - cf = ChunkFilter(paths=paths, tags=tags, exclude_paths=exclude_paths) - if cf.is_empty(): - return cf - cf.resolved_paths = { - path for path, meta in self._nodes.items() - if cf.match_metadata(path, meta.metadata) - } - return cf - - # -- Index helpers ------------------------------------------------------- - - def _add_forward(self, metadata: FileMetadata) -> None: - for link in metadata.link: - self._backlinks[link].add(metadata.path) - - def _remove_forward(self, metadata: FileMetadata) -> None: - for link in metadata.link: - self._backlinks[link].discard(metadata.path) - - # -- Persistence --------------------------------------------------------- - - def save(self, path: str | Path) -> None: - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - raw = {p: meta.model_dump(mode="json") for p, meta in self._nodes.items()} - content = json.dumps(raw, ensure_ascii=False) - temp = path.with_suffix(".tmp") - temp.write_text(content, encoding="utf-8") - temp.replace(path) - - @classmethod - def load(cls, path: str | Path) -> "FileGraph": - path = Path(path) - graph = cls() - if not path.exists(): - return graph - raw: dict = json.loads(path.read_text(encoding="utf-8")) - nodes = [FileMetadata(**meta) for meta in raw.values()] - graph.create(*nodes) - return graph - - # -- Dunder -------------------------------------------------------------- - - @property - def nodes(self) -> dict[str, FileMetadata]: - return self._nodes - - def __len__(self) -> int: - return len(self._nodes) - - def __contains__(self, path: str) -> bool: - return path in self._nodes diff --git a/reme2/schema/file_metadata.py b/reme2/schema/file_metadata.py index 86caf06d..828eee58 100644 --- a/reme2/schema/file_metadata.py +++ b/reme2/schema/file_metadata.py @@ -5,5 +5,4 @@ class FileMetadata(BaseModel): file: str = Field(...) path: str = Field(...) st_mtime: float = Field(...) - link: list[str] = Field(default_factory=list) metadata: dict = Field(default_factory=dict) diff --git a/reme2/schema/parsed_file.py b/reme2/schema/parsed_file.py new file mode 100644 index 00000000..8e583cec --- /dev/null +++ b/reme2/schema/parsed_file.py @@ -0,0 +1,28 @@ +"""ParsedFile — Parser's single output, carrying everything one file pass produces. + +Inherits `FileMetadata` (file / path / st_mtime / metadata) and adds the +parsed contents: + + chunks: list[FileChunk] — semantic blocks (text + position + hash); + embeddings ARE attached here — the parser + owns the embedding step (with hash-diff + cache via `existing_chunks` parameter). + edges: list[FileEdge] — wikilink graph edges (regex / frontmatter / + LLM provenance preserved per-edge). + +The Watcher passes a `ParsedFile` into `file_store.upsert_parsed(...)`, +which dispatches the three sub-payloads (meta / edges / chunks) to the +appropriate persistence pipelines while keeping the upsert atomic at +the store layer. +""" + +from pydantic import Field + +from .file_chunk import FileChunk +from .file_edge import FileEdge +from .file_metadata import FileMetadata + + +class ParsedFile(FileMetadata): + chunks: list[FileChunk] = Field(default_factory=list) + edges: list[FileEdge] = Field(default_factory=list) diff --git a/reme2/schema/vault/__init__.py b/reme2/schema/vault/__init__.py new file mode 100644 index 00000000..1bf89733 --- /dev/null +++ b/reme2/schema/vault/__init__.py @@ -0,0 +1,41 @@ +"""Vault business schemas — domain models for the markdown vault. + +These are the *business objects* the user stores in the vault (Topic, +Event), distinct from the engine schemas in `reme2/schema/` (FileMetadata, +FileChunk, ChunkFilter — internal data types). + +Single Topic class covers all categories under topics/ (no satellite / +tentacle subclassing); folder-topic is identified by path convention. + +Lives under `reme2/schema/vault/` (not in `reme2/mcp/`) so memory +services (Maintainer.lint, Ingestor) can validate frontmatter without +importing the MCP transport layer — that's what was creating the +mcp ↔ memory dependency cycle. +""" + +from .event import Event, EventStatus +from .frontmatter import VaultBaseFrontmatter, parse_frontmatter +from .registry import ALL_KNOWN_CATEGORIES, schema_for +from .topic import ( + INDEX_CATEGORIES, + JUDGMENT_CATEGORIES, + CONTENT_CATEGORIES, + Confidence, + Topic, + TopicCategory, +) + +__all__ = [ + "ALL_KNOWN_CATEGORIES", + "CONTENT_CATEGORIES", + "Confidence", + "Event", + "EventStatus", + "INDEX_CATEGORIES", + "JUDGMENT_CATEGORIES", + "Topic", + "TopicCategory", + "VaultBaseFrontmatter", + "parse_frontmatter", + "schema_for", +] diff --git a/reme2/schema/vault/event.py b/reme2/schema/vault/event.py new file mode 100644 index 00000000..0ec580f7 --- /dev/null +++ b/reme2/schema/vault/event.py @@ -0,0 +1,16 @@ +"""Event schema — task process records under events/{date}/{name}/.""" + +from typing import Literal + +from .frontmatter import VaultBaseFrontmatter + +EventStatus = Literal["active", "distilled", "archived"] + + +class Event(VaultBaseFrontmatter): + """events/{YYYY-MM-DD}/{name}/{name}.md.""" + + category: Literal["event"] = "event" # type: ignore[assignment] + status: EventStatus = "active" + topics: list[str] = [] + originSessionId: str | None = None diff --git a/reme2/schema/vault/frontmatter.py b/reme2/schema/vault/frontmatter.py new file mode 100644 index 00000000..4b5a3c62 --- /dev/null +++ b/reme2/schema/vault/frontmatter.py @@ -0,0 +1,38 @@ +"""Common frontmatter schema shared by all vault files.""" + +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class VaultBaseFrontmatter(BaseModel): + """Fields present on every vault file.""" + + model_config = ConfigDict(extra="allow") + + title: str = Field(...) + description: str = Field(default="") + tags: list[str] = Field(default_factory=list) + category: str = Field(...) + created: date | None = None + updated: date | None = None + + @field_validator("created", "updated", mode="before") + @classmethod + def _coerce_date(cls, v): + if v is None or isinstance(v, date): + return v + if isinstance(v, datetime): + return v.date() + if isinstance(v, str): + return datetime.fromisoformat(v).date() + return v + + +def parse_frontmatter(raw: dict[str, Any]) -> VaultBaseFrontmatter: + """Tolerant parse — never raises; missing required fields fall back to defaults.""" + safe = dict(raw or {}) + safe.setdefault("title", safe.get("name") or "") + safe.setdefault("category", "unknown") + return VaultBaseFrontmatter.model_validate(safe) diff --git a/reme2/schema/vault/registry.py b/reme2/schema/vault/registry.py new file mode 100644 index 00000000..8d539087 --- /dev/null +++ b/reme2/schema/vault/registry.py @@ -0,0 +1,27 @@ +"""Map vault category → strict pydantic model for validation. + +Used by the Maintainer to derive lint rules from schema instead of +hardcoding. Two business object schemas: + - event → Event + - everything else → Topic (judgment subset enforced via model_validator) +""" + +from typing import get_args + +from pydantic import BaseModel + +from .event import Event +from .topic import Topic, TopicCategory + +_REGISTRY: dict[str, type[BaseModel]] = {"event": Event} +for _cat in get_args(TopicCategory): + _REGISTRY[_cat] = Topic + +ALL_KNOWN_CATEGORIES: frozenset[str] = frozenset(_REGISTRY.keys()) + + +def schema_for(category: str | None) -> type[BaseModel] | None: + """Return the pydantic schema bound to a category, or None for unknown.""" + if not category: + return None + return _REGISTRY.get(category) diff --git a/reme2/schema/vault/topic.py b/reme2/schema/vault/topic.py new file mode 100644 index 00000000..c54f6d49 --- /dev/null +++ b/reme2/schema/vault/topic.py @@ -0,0 +1,47 @@ +"""Topic schema — every .md under topics/ is a Topic. + +No satellite/tentacle/folder note subclassing. Folder topic = Topic whose +filename equals its parent directory name (path convention; not a schema field). +""" + +from typing import Literal + +from pydantic import model_validator + +from .frontmatter import VaultBaseFrontmatter + +# Topic categories grouped by usage: +# - Index categories: usually appear as folder topics; may have ticker/market +# - Content categories: cluster siblings; thesis/model/questions need confidence +INDEX_CATEGORIES = {"company", "sector", "concept", "method", "tool", "profile"} +JUDGMENT_CATEGORIES = {"thesis", "model", "questions"} +CONTENT_CATEGORIES = JUDGMENT_CATEGORIES | {"fundamentals"} + +TopicCategory = Literal[ + "company", "sector", "concept", "method", "tool", "profile", + "thesis", "model", "questions", "fundamentals", +] +Confidence = Literal["⏳", "✅", "❌"] + + +class Topic(VaultBaseFrontmatter): + """topics/{folder}/{name}.md — long-lived cognitive memory node. + + Folder topic is identified by path convention: filename stem == parent + folder name. Not represented in this schema directly; checked at runtime + via `Path(p).stem == Path(p).parent.name`. + """ + + category: TopicCategory # type: ignore[assignment] + market: str | None = None + ticker: str | None = None + confidence: Confidence | None = None + + @model_validator(mode="after") + def _confidence_required_for_judgments(self): + if self.category in JUDGMENT_CATEGORIES and self.confidence is None: + raise ValueError( + f"category={self.category} requires explicit confidence " + f"(one of ⏳ / ✅ / ❌)" + ) + return self diff --git a/reme2/utils/__init__.py b/reme2/utils/__init__.py index 19595659..d089c295 100644 --- a/reme2/utils/__init__.py +++ b/reme2/utils/__init__.py @@ -1,17 +1,24 @@ """Utility modules""" from .case_converter import camel_to_snake, snake_to_camel -from .chunking_utils import chunk_markdown from .common_utils import hash_text, execute_stream_task, run_coro_safely from .logger_utils import get_logger from .logo_utils import print_logo from .similarity_utils import cosine_similarity, batch_cosine_similarity from .singleton import singleton +from .wikilink import ( + InlineField, + extract_inline_fields, + extract_typed_edges, + extract_wikilinks, + extract_wikilinks_from_metadata, + parse_wikilinks, + parse_wikilinks_from_metadata, +) __all__ = [ "camel_to_snake", "snake_to_camel", - "chunk_markdown", "hash_text", "execute_stream_task", "run_coro_safely", @@ -20,4 +27,11 @@ __all__ = [ "cosine_similarity", "batch_cosine_similarity", "singleton", + "InlineField", + "extract_wikilinks", + "extract_wikilinks_from_metadata", + "parse_wikilinks", + "parse_wikilinks_from_metadata", + "extract_inline_fields", + "extract_typed_edges", ] diff --git a/reme2/utils/chunking_utils.py b/reme2/utils/chunking_utils.py deleted file mode 100644 index 9e94d5d3..00000000 --- a/reme2/utils/chunking_utils.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Markdown file chunking utilities. - -Provides functionality to split Markdown documents into smaller chunks -while maintaining overlap between consecutive chunks for context preservation. -""" - -from .common_utils import hash_text -from ..schema import FileChunk - - -def chunk_markdown( - text: str, - path: str, - chunk_tokens: int, - overlap: int, -) -> list[FileChunk]: - """Split Markdown text into chunks with configurable size and overlap. - - Implements a sliding window approach to chunk Markdown content while - preserving context through overlap between consecutive chunks. Token - counts are approximated using a 1:4 ratio (1 token ≈ 4 characters). - - Args: - text: Input Markdown text to be chunked. - path: File path identifier for the source document. - chunk_tokens: Maximum number of tokens per chunk. Will be converted - to characters using the 1:4 ratio, with a minimum of 32 characters. - overlap: Number of overlapping tokens between consecutive chunks. - Helps maintain context across chunk boundaries. - - Returns: - A list of FileChunk objects, each containing: - - id: Unique identifier based on path, line numbers, and hash - - path: The source file path - - start_line: Starting line number (1-indexed) - - end_line: Ending line number (1-indexed) - - text: The chunk content - - hash: SHA-256 hash of the chunk content - - Examples: - >>> text = "# Header\\nParagraph content here.\\n\\n## Subheader" - >>> chunks = chunk_markdown(text, "doc.md", chunk_tokens=100, overlap=20) - >>> len(chunks) - 1 - >>> chunks[0].path - 'doc.md' - """ - if not text.strip(): - return [] - - lines = text.split("\n") - - # Convert tokens to characters (~1 token = 4 chars) - max_chars = max(32, chunk_tokens * 4) - overlap_chars = max(0, overlap * 4) - - chunks: list[FileChunk] = [] - - # Currently building chunk - current: list[dict] = [] # [{'line': str, 'line_no': int}] - current_chars = 0 - - def flush() -> None: - """Add current chunk to results list.""" - if not current: - return - - first_entry = current[0] - last_entry = current[-1] - - if not first_entry or not last_entry: - return - - chunk_text = "\n".join([entry["line"] for entry in current]) - start_line = first_entry["line_no"] - end_line = last_entry["line_no"] - - chunk_hash = hash_text(chunk_text) - - chunks.append( - FileChunk( - id=hash_text(f"{path}:{start_line}:{end_line}:{chunk_hash}:{len(chunks)}"), - path=path, - start_line=start_line, - end_line=end_line, - text=chunk_text, - hash=chunk_hash, - ), - ) - - def carry_overlap() -> None: - """Keep overlapping part and clear the rest.""" - nonlocal current, current_chars - - if overlap_chars <= 0 or not current: - current = [] - current_chars = 0 - return - - acc = 0 - kept = [] - - # Collect lines from the end until reaching overlap size - for j in range(len(current) - 1, -1, -1): - entry = current[j] - if not entry: - continue - - acc += len(entry["line"]) + 1 # +1 for newline - kept.insert(0, entry) # Insert at the beginning to maintain order - - if acc >= overlap_chars: - break - - current = kept - current_chars = sum(len(entry["line"]) + 1 for entry in kept) - - for i, line in enumerate(lines): - line_no = i + 1 - - # Split long lines into multiple segments - segments = [] - if not line: # Empty line - segments.append("") - else: - # If line is too long, split by maximum character count - for start in range(0, len(line), max_chars): - segments.append(line[start: start + max_chars]) - - for segment in segments: - line_size = len(segment) + 1 # +1 for newline - - # If adding current segment would exceed the limit, flush current chunk - if current_chars + line_size > max_chars and current: - flush() - carry_overlap() - - current.append({"line": segment, "line_no": line_no}) - current_chars += line_size - - # Process the final chunk - flush() - - return [c for c in chunks if c.text.strip()] diff --git a/reme2/utils/common_utils.py b/reme2/utils/common_utils.py index 513cb8ec..6c86d487 100644 --- a/reme2/utils/common_utils.py +++ b/reme2/utils/common_utils.py @@ -47,10 +47,10 @@ def hash_text(text: str, encoding: str = "utf-8") -> str: async def execute_stream_task( - stream_queue: asyncio.Queue, - task: asyncio.Task, - task_name: str | None = None, - output_format: Literal["str", "bytes", "chunk"] = "str", + stream_queue: asyncio.Queue, + task: asyncio.Task, + task_name: str | None = None, + output_format: Literal["str", "bytes", "chunk"] = "str", ) -> AsyncGenerator[str | bytes | StreamChunk, None]: """Core stream flow execution logic. diff --git a/reme2/utils/logger_utils.py b/reme2/utils/logger_utils.py index b9648152..0c2eeb70 100644 --- a/reme2/utils/logger_utils.py +++ b/reme2/utils/logger_utils.py @@ -14,11 +14,11 @@ _initialized = False def get_logger( - log_dir: str = "logs", - level: str = "INFO", - log_to_console: bool = True, - log_to_file: bool = True, - force_init: bool = False, + log_dir: str = "logs", + level: str = "INFO", + log_to_console: bool = True, + log_to_file: bool = True, + force_init: bool = False, ): """Get a configured logger instance. diff --git a/reme2/utils/vault_paths.py b/reme2/utils/vault_paths.py new file mode 100644 index 00000000..52394bc7 --- /dev/null +++ b/reme2/utils/vault_paths.py @@ -0,0 +1,78 @@ +"""Vault path generation + naming disambiguation. + +Pure-Python helpers consumed by the MCP step shells (`sync`, +`topic_create`) and any future flow that needs to materialize a path +under the vault layout. No async, no MCP awareness — easy to unit-test. + +Wikilink uniqueness is a *graph* property, not a path-builder concern — +see `reme2.component.file_store.BaseFileStore.collisions_after_create` +and `BaseFileStore.all_ambiguous_wikilinks`. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from datetime import date as date_type +from pathlib import Path + + +def event_path( + vault_root: str | Path, + name: str, + on_date: date_type | str | None = None, + events_dir: str = "events", +) -> Path: + """events/{YYYY-MM-DD}/{name}/{name}.md under the given vault root.""" + if on_date is None: + on_date = date_type.today() + if isinstance(on_date, date_type): + on_date = on_date.isoformat() + return Path(vault_root) / events_dir / on_date / name / f"{name}.md" + + +def topic_path( + vault_root: str | Path, + folder: str, + name: str, + topics_dir: str = "topics", +) -> Path: + """topics/{folder}/{name}.md under the given vault root.""" + return Path(vault_root) / topics_dir / folder / f"{name}.md" + + +def is_folder_topic(path: str | Path) -> bool: + """True if filename stem == parent directory name (folder note convention).""" + p = Path(path) + return p.stem == p.parent.name + + +def next_suffixed_stem(taken: Iterable[str], base: str) -> str: + """Lowest unused `-N` (N≥2). Returns `base` itself if not taken. + + Used by topic_create / sync when a same-stem (topic) or + same-name-on-same-day (event) collision is detected — the suggested + suffix is *advisory*; the create still rejects so the agent can pick + a domain-specific qualifier (e.g., `Apple-Inc` vs `Apple-Fruit`) + that carries more meaning than a numeric suffix. + + Examples (with taken={"BABA", "BABA-2"}): + next_suffixed_stem(taken, "BABA") == "BABA-3" + next_suffixed_stem(taken, "MSFT") == "MSFT" + """ + taken_set = set(taken) + if base not in taken_set: + return base + pattern = re.compile(rf"^{re.escape(base)}-(\d+)$") + used: set[int] = set() + for s in taken_set: + m = pattern.match(s) + if m: + try: + used.add(int(m.group(1))) + except ValueError: + continue + n = 2 + while n in used: + n += 1 + return f"{base}-{n}" diff --git a/reme2/utils/wikilink.py b/reme2/utils/wikilink.py new file mode 100644 index 00000000..cc0545e8 --- /dev/null +++ b/reme2/utils/wikilink.py @@ -0,0 +1,259 @@ +"""Wikilink protocol — Obsidian-compatible bare links + Dataview-style typed edges. + +## Bare wikilinks (Obsidian) + + [[Note]] short form (resolve by stem) + [[path/to/Note]] path form + [[Note#Section]] heading anchor + [[Note#^block-id]] block anchor + [[Note|Alias]] display alias + ![[Note]] embed (transclusion) + ![[Note#Section|Alias]] all combined + +## Typed wikilinks / property edges (Dataview inline field syntax) + + [predicate:: [[Target]]] typed link (bracketed, visible) + (predicate:: [[Target]]) typed link (parenthesized, hidden in render) + [predicate:: scalar value] typed scalar (becomes a property, not a graph edge) + +`predicate` is identifier-shaped: starts with a letter, then letters / +digits / `_` / `-`. The link inside follows the bare protocol above. + +## Output schema + +Wikilink edges are returned as `FileEdge` (the persistence model exposed +via `file_store.get_edges(path)`). Typed scalar properties are returned +as `InlineField` — they carry no link target, just `{predicate, value}`. + +## Functions + +Bare extraction (back-compat — return targets only): + extract_wikilinks(text) → list[str] + extract_wikilinks_from_metadata(d) → list[str] + +Structured parsing (return FileEdge): + parse_wikilinks(text) → list[FileEdge] + parse_wikilinks_from_metadata(d) → list[FileEdge] + extract_inline_fields(text) → list[InlineField] + extract_typed_edges(text) → list[FileEdge] (predicate-bearing only) +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal + +from ..schema.file_edge import FileEdge + + +EdgeSource = Literal["regex", "frontmatter", "llm"] + + +# -- Regexes -------------------------------------------------------------- + +# Bare wikilink. Group(1) = target, kept as the FIRST capturing group so +# legacy callers using `m.group(1)` (e.g. wikilink rewriters) still work. +# The leading `(?:!)?` is non-capturing — read the embed prefix off +# `m.group(0).startswith("!")` instead. +WIKILINK_RE = re.compile( + r""" + (?:!)? + \[\[ + (?P[^\]\|\#\n]+?) + (?:\#(?P[^\]\|\n]+))? + (?:\|(?P[^\]\n]+))? + \]\] + """, + re.VERBOSE, +) + +# Dataview inline field: [pred:: value] or (pred:: value). Value may be a +# bare wikilink OR a scalar with no inner brackets. The open/close bracket +# pair isn't enforced by the regex — `_bracket_pair_ok` rejects mismatched +# `[..)`-style fragments. +INLINE_FIELD_RE = re.compile( + r""" + (?P[\[\(]) + (?P[A-Za-z][\w\-]*) + \s*::\s* + (?P + (?: !? \[\[ [^\]\|\#\n]+? (?:\#[^\]\|\n]+)? (?:\|[^\]\n]+)? \]\] ) + | + (?: [^\]\)\n]+? ) + ) + (?P[\]\)]) + """, + re.VERBOSE, +) + + +# -- Auxiliary schema (typed scalar) -------------------------------------- + + +@dataclass(frozen=True, slots=True) +class InlineField: + """Typed scalar property — `[predicate:: value]` where value is NOT a wikilink. + + Distinct from `FileEdge`: a scalar property doesn't point at another file, + so it's not a graph edge — it's an attribute on the source file. + """ + + predicate: str + value: str + + +# -- Internal helpers ----------------------------------------------------- + + +def _edge_from_match( + m: re.Match, + *, + predicate: str | None = None, + source: EdgeSource = "regex", +) -> FileEdge: + anchor = m.group("anchor") + alias = m.group("alias") + return FileEdge( + target=m.group("target").strip(), + anchor=anchor.strip() if anchor else None, + alias=alias.strip() if alias else None, + embed=m.group(0).startswith("!"), + predicate=predicate, + source=source, + ) + + +def _bracket_pair_ok(open_ch: str, close_ch: str) -> bool: + return (open_ch == "[" and close_ch == "]") or (open_ch == "(" and close_ch == ")") + + +# -- Public API: bare extraction (back-compat) ---------------------------- + + +def extract_wikilinks(text: str) -> list[str]: + """Targets-only list of wikilinks found in text (no dedup). + + Includes wikilinks inside typed wrappers — the watcher's graph + projection should see every link target, with or without a predicate. + Use `parse_wikilinks` if you need the predicate too. + """ + if not text: + return [] + return [m.group("target").strip() for m in WIKILINK_RE.finditer(text)] + + +def extract_wikilinks_from_metadata(metadata: dict) -> list[str]: + """Recursively walk a frontmatter dict, extracting wikilink targets from string leaves.""" + links: list[str] = [] + + def _walk(value) -> None: + if isinstance(value, str): + links.extend(extract_wikilinks(value)) + elif isinstance(value, dict): + for v in value.values(): + _walk(v) + elif isinstance(value, (list, tuple, set)): + for item in value: + _walk(item) + + _walk(metadata) + return links + + +# -- Public API: structured parsing --------------------------------------- + + +def parse_wikilinks(text: str) -> list[FileEdge]: + """Structured parse — bare wikilinks AND typed-wrapper predicates. + + Order is by source position. A wikilink inside a typed wrapper + appears once, with its predicate attached. All edges have + `source="regex"`. + """ + if not text: + return [] + + typed_spans: list[tuple[int, int]] = [] + items: list[tuple[int, FileEdge]] = [] + + for m in INLINE_FIELD_RE.finditer(text): + if not _bracket_pair_ok(m.group("open"), m.group("close")): + continue + value = m.group("value").strip() + wm = WIKILINK_RE.fullmatch(value) + if wm is None: + continue # scalar — handled by extract_inline_fields + items.append(( + m.start(), + _edge_from_match(wm, predicate=m.group("predicate").strip()), + )) + typed_spans.append(m.span()) + + for m in WIKILINK_RE.finditer(text): + s, e = m.span() + if any(ts <= s and e <= te for ts, te in typed_spans): + continue # this bare match is the inner link of a typed wrapper + items.append((s, _edge_from_match(m))) + + items.sort(key=lambda pair: pair[0]) + return [w for _, w in items] + + +def extract_inline_fields(text: str) -> list[InlineField]: + """Typed scalar fields — `[pred:: value]` whose value is NOT a wikilink. + + Link-valued typed fields are returned by `parse_wikilinks` instead, + since they're graph edges rather than scalar properties. + """ + if not text: + return [] + out: list[InlineField] = [] + for m in INLINE_FIELD_RE.finditer(text): + if not _bracket_pair_ok(m.group("open"), m.group("close")): + continue + value = m.group("value").strip() + if WIKILINK_RE.fullmatch(value) is not None: + continue + out.append(InlineField(predicate=m.group("predicate").strip(), value=value)) + return out + + +def extract_typed_edges(text: str) -> list[FileEdge]: + """Sugar: just the predicate-bearing wikilinks (typed graph edges).""" + return [w for w in parse_wikilinks(text) if w.is_typed] + + +def parse_wikilinks_from_metadata(metadata: dict) -> list[FileEdge]: + """Recursive frontmatter parse — frontmatter keys act as predicates. + + `author: "[[John]]"` → `FileEdge(target="John", predicate="author", source="frontmatter")`. + `related: ["[[X]]", "[[Y]]"]` → two `FileEdge(predicate="related", ...)`. + A bare wikilink in a string value with no parent key keeps `predicate=None`. + Inline `[pred:: ...]` syntax inside a string value still wins over the + inherited frontmatter key (predicate carried from the inline parse). + All edges have `source="frontmatter"`. + """ + links: list[FileEdge] = [] + + def _walk(value, predicate: str | None) -> None: + if isinstance(value, str): + for w in parse_wikilinks(value): + effective = w.predicate if w.predicate is not None else predicate + links.append(FileEdge( + target=w.target, + anchor=w.anchor, + alias=w.alias, + embed=w.embed, + predicate=effective, + source="frontmatter", + )) + elif isinstance(value, dict): + for k, v in value.items(): + _walk(v, predicate=str(k)) + elif isinstance(value, (list, tuple, set)): + for item in value: + _walk(item, predicate=predicate) + + _walk(metadata, predicate=None) + return links diff --git a/reme2/vector_based/__init__.py b/reme2/vector_based/__init__.py deleted file mode 100644 index e69de29b..00000000