feat: add file parser support and search filtering

- Add file_parser component with default implementation
- Introduce SearchFilter schema for path and tag filtering
- Implement filter functionality in BaseFileStore and LocalFileStore
- Update file watcher to use parser-based filtering instead of suffix filters
- Register new FILE_PARSER component enum
- Add test_data directory to gitignore

refactor: improve component imports and initialization

- Fix relative imports in application.py
- Add file_parser import to component init
- Initialize registry dict when component type doesn't exist
- Remove circular import in HttpService by using string annotation
- Update config yaml to use proper component names

refactor: enhance file watcher architecture

- Replace MdFileWatcher with more flexible FullFileWatcher and LightFileWatcher
- Remove suffix-based filtering in favor of parser-based approach
- Update BaseFileWatcher to resolve parsers from app context
- Remove unused watch_filter method

refactor: update ReMe core functionality

- Remove memory_path creation
- Simplify dream and proactive methods to return empty strings
- Update config defaults for HTTP service and component backends

docs: update component configuration in paw.yaml

- Change service backend from cmd to http
- Rename components to use correct singular forms
- Add default file parser and file watcher configurations
- Set up local file store with default settings
```
This commit is contained in:
huangsen 2026-04-21 16:39:33 +08:00
parent b202fc3fba
commit 00018c0ff8
30 changed files with 749 additions and 269 deletions

3
.gitignore vendored
View file

@ -42,4 +42,5 @@ meta_memory/*
**/data/*.json
*.db
memories/*
.reme/*
.reme/*
/test_data

View file

@ -4,7 +4,7 @@ import asyncio
from pathlib import Path
from typing import AsyncGenerator
from enumeration import ComponentEnum
from .enumeration import ComponentEnum
from .component import BaseComponent, ApplicationContext
from .schema import Response, StreamChunk
from .utils import execute_stream_task, print_logo, get_logger
@ -122,10 +122,10 @@ class Application(BaseComponent):
stream_queue = asyncio.Queue()
task = asyncio.create_task(job(stream_queue=stream_queue, app_context=self.context, **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

View file

@ -9,9 +9,9 @@ from .runtime_context import RuntimeContext
from . import as_llm
from . import as_llm_formatter
from . import as_token_counter
from . import client
from . import embedding
from . import file_parser
from . import file_store
from . import file_watcher
from . import job
@ -28,9 +28,9 @@ __all__ = [
# base components
"as_llm",
"as_llm_formatter",
"client",
"embedding",
"file_parser",
"file_store",
"file_watcher",
"job",

View file

@ -41,4 +41,4 @@ class EstimatedAsTokenCounter(BaseAsTokenCounter):
__all__ = [
"BaseAsTokenCounter",
"EstimatedAsTokenCounter",
]
]

View file

@ -31,13 +31,13 @@ class BaseStep(BaseComponent):
return instance
def __init__(
self,
name: str = "",
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,
name: str = "",
language: str = "",
prompt_dict: dict[str, str] | None = None,
input_mapping: dict[str, str] | None = None,
output_mapping: dict[str, str] | None = None,
**kwargs,
):
"""Initialize step configurations."""
super().__init__(**kwargs)

View file

@ -31,6 +31,8 @@ class ComponentRegistry:
raise ValueError("Component name cannot be empty")
component_type = cls.component_type
if component_type not in self._registry:
self._registry[component_type] = {}
if name in self._registry[component_type]:
self.logger.warning(f"Component '{name}' already registered for {component_type}, overwriting")

View file

@ -0,0 +1,11 @@
"""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
__all__ = [
"BaseFileParser",
"DefaultFileParser",
"MdFileParser",
]

View file

@ -0,0 +1,41 @@
"""Abstract base class for file parsers."""
from abc import abstractmethod
from ..base_component import BaseComponent
from ...enumeration import ComponentEnum
from ...schema import FileChunk, FileMetadata
class BaseFileParser(BaseComponent):
"""Abstract base class for file format parsers.
Each parser declares which file suffixes it handles and implements
the parse method to produce FileMetadata and FileChunks.
"""
component_type = ComponentEnum.FILE_PARSER
suffixes: list[str] = []
def __init__(self, chunk_tokens: int = 400, chunk_overlap: int = 80, **kwargs):
super().__init__(**kwargs)
self.chunk_tokens = chunk_tokens
self.chunk_overlap = chunk_overlap
async def _start(self, app_context=None):
pass
async def _close(self):
pass
@abstractmethod
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
"""Parse a file into metadata and chunks.
Args:
path: Absolute path to the file.
Returns:
Tuple of (FileMetadata, list of FileChunks).
"""

View file

@ -0,0 +1,64 @@
"""Default file parser for unknown file types."""
import asyncio
import hashlib
from pathlib import Path
from .base_file_parser import BaseFileParser
from ..component_registry import R
from ...schema import FileChunk, FileMetadata
from ...utils import hash_text, chunk_markdown
@R.register("default")
class DefaultFileParser(BaseFileParser):
"""Fallback parser for unknown file types.
Attempts to read as text and chunk. If the file is binary,
stores metadata only with no chunks.
"""
suffixes = []
def __init__(self, encoding: str = "utf-8", **kwargs):
super().__init__(**kwargs)
self.encoding = encoding
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
file_path = Path(path)
def _read_file():
stat = file_path.stat()
raw = file_path.read_bytes()
try:
content = raw.decode(self.encoding)
content_hash = hash_text(content)
return stat, content_hash, content
except (UnicodeDecodeError, ValueError):
binary_hash = hashlib.sha256(raw).hexdigest()
return stat, binary_hash, None
stat, file_hash, content = await asyncio.to_thread(_read_file)
file_meta = FileMetadata(
hash=file_hash,
mtime_ms=stat.st_mtime * 1000,
size=stat.st_size,
path=str(file_path.absolute()),
content=content,
)
chunks: list[FileChunk] = []
if content:
chunks = (
chunk_markdown(
content,
file_meta.path,
self.chunk_tokens,
self.chunk_overlap,
)
or []
)
file_meta.chunk_count = len(chunks)
return file_meta, chunks

View file

@ -0,0 +1,55 @@
"""Markdown file parser."""
import asyncio
from pathlib import Path
import frontmatter
from .base_file_parser import BaseFileParser
from ..component_registry import R
from ...schema import FileChunk, FileMetadata
from ...utils import hash_text, chunk_markdown
@R.register("md")
class MdFileParser(BaseFileParser):
"""Parser for Markdown files with YAML frontmatter support."""
suffixes = [".md", ".markdown"]
def __init__(self, encoding: str = "utf-8", **kwargs):
super().__init__(**kwargs)
self.encoding = encoding
async def parse(self, path: str) -> tuple[FileMetadata, list[FileChunk]]:
file_path = Path(path)
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
stat, metadata, content = await asyncio.to_thread(_read_and_parse)
file_meta = FileMetadata(
hash=hash_text(content),
mtime_ms=stat.st_mtime * 1000,
size=stat.st_size,
path=str(file_path.absolute()),
content=content,
metadata=metadata,
)
chunks = (
chunk_markdown(
content,
file_meta.path,
self.chunk_tokens,
self.chunk_overlap,
)
or []
)
file_meta.chunk_count = len(chunks)
return file_meta, chunks

View file

@ -7,7 +7,7 @@ from pathlib import Path
from ..base_component import BaseComponent
from ..embedding import BaseEmbeddingModel
from ...enumeration import ComponentEnum
from ...schema import FileChunk, FileMetadata
from ...schema import FileChunk, FileMetadata, SearchFilter
class BaseFileStore(BaseComponent):
@ -145,12 +145,36 @@ class BaseFileStore(BaseComponent):
async def get_file_chunks(self, path: str) -> list[FileChunk]:
"""Get all chunks for a file."""
def _apply_filter(
self,
chunks: list[FileChunk],
search_filter: SearchFilter | None,
file_metadata: dict[str, FileMetadata] | None = None,
) -> list[FileChunk]:
"""Apply search filter to a list of chunks.
Args:
chunks: Candidate chunks to filter.
search_filter: Filter conditions.
file_metadata: File-level metadata lookup (path -> FileMetadata).
Used for tag filtering since tags are file-level, not chunk-level.
"""
if not search_filter or search_filter.is_empty():
return chunks
fm = file_metadata or {}
return [c for c in chunks if search_filter.match(c.path, fm[c.path].metadata if c.path in fm else None)]
@abstractmethod
async def vector_search(self, query: str, limit: int) -> list[FileChunk]:
async def vector_search(self, query: str, limit: int, search_filter: SearchFilter | None = None) -> list[FileChunk]:
"""Perform vector similarity search."""
@abstractmethod
async def keyword_search(self, query: str, limit: int) -> list[FileChunk]:
async def keyword_search(
self,
query: str,
limit: int,
search_filter: SearchFilter | None = None,
) -> list[FileChunk]:
"""Perform full-text/keyword search."""
@abstractmethod
@ -160,6 +184,7 @@ class BaseFileStore(BaseComponent):
limit: int,
vector_weight: float = 0.7,
candidate_multiplier: float = 3.0,
search_filter: SearchFilter | None = None,
) -> list[FileChunk]:
"""Perform hybrid search combining vector and keyword results.
@ -168,6 +193,7 @@ class BaseFileStore(BaseComponent):
limit: Maximum number of results.
vector_weight: Weight for vector scores (0.0-1.0).
candidate_multiplier: Multiplier for candidate pool size.
search_filter: Optional filter for paths/tags.
Returns:
FileChunk list with score populated, sorted by relevance.

View file

@ -7,7 +7,7 @@ import numpy as np
from .base_file_store import BaseFileStore
from ..component_registry import R
from ...schema import FileChunk, FileMetadata
from ...schema import FileChunk, FileMetadata, SearchFilter
from ...utils import batch_cosine_similarity
@ -72,9 +72,7 @@ class LocalFileStore(BaseFileStore):
async def _save_metadata(self) -> None:
"""Persist file metadata to JSON file with atomic write."""
raw = {
path: meta.model_dump(exclude={"content", "metadata"}, mode="json") for path, meta in self._files.items()
}
raw = {path: meta.model_dump(exclude={"content"}, mode="json") for path, meta in self._files.items()}
content = json.dumps(raw, indent=2, ensure_ascii=False)
temp_path = self._metadata_file.with_suffix(".tmp")
try:
@ -97,7 +95,7 @@ class LocalFileStore(BaseFileStore):
f"LocalFileStore '{self.store_name}' ready: "
f"{len(self._chunks)} chunks, metadata at {self._metadata_file}",
)
await super()._start()
await super()._start(app_context)
async def _close(self) -> None:
"""Flush state to disk and clear memory."""
@ -111,14 +109,12 @@ class LocalFileStore(BaseFileStore):
async def upsert_file(self, file_meta: FileMetadata, chunks: list[FileChunk]) -> None:
"""Insert or update a file and its chunks."""
if not chunks:
return
await self.delete_file(file_meta.path)
chunks = await self.get_chunk_embeddings(chunks)
for chunk in chunks:
self._chunks[chunk.id] = chunk
if chunks:
chunks = await self.get_chunk_embeddings(chunks)
for chunk in chunks:
self._chunks[chunk.id] = chunk
self._files[file_meta.path] = FileMetadata(
hash=file_meta.hash,
@ -126,6 +122,7 @@ class LocalFileStore(BaseFileStore):
size=file_meta.size,
path=file_meta.path,
chunk_count=len(chunks),
metadata=file_meta.metadata,
)
async def delete_file(self, path: str) -> None:
@ -170,6 +167,7 @@ class LocalFileStore(BaseFileStore):
size=file_meta.size,
path=file_meta.path,
chunk_count=file_meta.chunk_count,
metadata=file_meta.metadata,
)
async def get_file_chunks(self, path: str) -> list[FileChunk]:
@ -180,7 +178,7 @@ class LocalFileStore(BaseFileStore):
# -- Search -------------------------------------------------------------
async def vector_search(self, query: str, limit: int) -> list[FileChunk]:
async def vector_search(self, query: str, limit: int, search_filter: SearchFilter | None = None) -> list[FileChunk]:
"""Cosine-similarity vector search over in-memory embeddings."""
if not self.vector_enabled or not query:
return []
@ -189,7 +187,11 @@ class LocalFileStore(BaseFileStore):
if not query_embedding:
return []
candidates = [c for c in self._chunks.values() if c.embedding]
candidates = self._apply_filter(
[c for c in self._chunks.values() if c.embedding],
search_filter,
self._files,
)
if not candidates:
return []
@ -226,7 +228,12 @@ class LocalFileStore(BaseFileStore):
results.sort(key=lambda r: r.score, reverse=True)
return results[:limit]
async def keyword_search(self, query: str, limit: int) -> list[FileChunk]:
async def keyword_search(
self,
query: str,
limit: int,
search_filter: SearchFilter | None = None,
) -> list[FileChunk]:
"""Keyword search via substring matching."""
if not self.fts_enabled or not query:
return []
@ -239,8 +246,10 @@ class LocalFileStore(BaseFileStore):
words_lower = [w.lower() for w in words]
n_words = len(words)
filtered_chunks = self._apply_filter(list(self._chunks.values()), search_filter, self._files)
results = []
for chunk in self._chunks.values():
for chunk in filtered_chunks:
text_lower = chunk.text.lower()
match_count = sum(1 for w in words_lower if w in text_lower)
if match_count == 0:
@ -271,6 +280,7 @@ class LocalFileStore(BaseFileStore):
limit: int,
vector_weight: float = 0.7,
candidate_multiplier: float = 3.0,
search_filter: SearchFilter | None = None,
) -> list[FileChunk]:
"""Hybrid search combining vector and keyword results."""
assert 0.0 <= vector_weight <= 1.0
@ -279,8 +289,8 @@ class LocalFileStore(BaseFileStore):
text_weight = 1.0 - vector_weight
if self.vector_enabled and self.fts_enabled:
keyword_results = await self.keyword_search(query, candidates)
vector_results = await self.vector_search(query, candidates)
keyword_results = await self.keyword_search(query, candidates, search_filter)
vector_results = await self.vector_search(query, candidates, search_filter)
if not keyword_results:
return vector_results[:limit]
@ -295,9 +305,9 @@ class LocalFileStore(BaseFileStore):
)
return merged[:limit]
elif self.vector_enabled:
return await self.vector_search(query, limit)
return await self.vector_search(query, limit, search_filter)
elif self.fts_enabled:
return await self.keyword_search(query, limit)
return await self.keyword_search(query, limit, search_filter)
return []
@staticmethod

View file

@ -1,9 +1,11 @@
"""File watcher implementations for monitoring file system changes."""
from .base_file_watcher import BaseFileWatcher
from .md_file_watcher import MdFileWatcher
from .full_file_watcher import FullFileWatcher
from .light_file_watcher import LightFileWatcher
__all__ = [
"BaseFileWatcher",
"MdFileWatcher",
"FullFileWatcher",
"LightFileWatcher",
]

View file

@ -7,6 +7,7 @@ from pathlib import Path
from watchfiles import Change, awatch
from ..base_component import BaseComponent
from ..file_parser import BaseFileParser
from ..file_store import BaseFileStore
from ...enumeration import ComponentEnum
@ -16,7 +17,7 @@ class BaseFileWatcher(BaseComponent):
Provides file monitoring with:
- watchfiles integration for efficient change detection
- Suffix-based filtering
- Parser-based file filtering
- Auto-restart on failure
- Optional index rebuild on start
"""
@ -26,34 +27,23 @@ class BaseFileWatcher(BaseComponent):
def __init__(
self,
watch_paths: list[str] | str,
suffix_filters: list[str] | None = None,
recursive: bool = False,
debounce: int = 2000,
chunk_tokens: int = 400,
chunk_overlap: int = 80,
file_store: str = "default",
default_parser: str | None = None,
rebuild_index_on_start: bool = True,
poll_delay_ms: int = 2000,
**kwargs,
):
"""Initialize file watcher configuration.
Args:
watch_paths: Paths to watch for changes.
suffix_filters: File suffix filters (e.g., ['.py', '.txt']).
recursive: Whether to watch directories recursively.
debounce: Debounce time in milliseconds.
chunk_tokens: Token size for chunking.
chunk_overlap: Overlap size for chunks.
file_store: Name of the file store component.
rebuild_index_on_start: Clear index and rescan files on start.
poll_delay_ms: Polling delay in milliseconds.
"""
super().__init__(**kwargs)
self._file_store_name: str = file_store
self._default_parser_name: str | None = default_parser
self.file_store: BaseFileStore | None = None
self._suffix_to_parser: dict[str, BaseFileParser] = {}
self._default_parser: BaseFileParser | None = None
self.watch_paths: list[str] = [watch_paths] if isinstance(watch_paths, str) else watch_paths
self.suffix_filters: list[str] = suffix_filters or []
self.recursive: bool = recursive
self.debounce: int = debounce
self.chunk_tokens: int = chunk_tokens
@ -68,6 +58,7 @@ class BaseFileWatcher(BaseComponent):
"""Resolve file_store and start watching task."""
if self._file_store_name:
assert app_context is not None, "app_context must be provided"
stores = 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.")
@ -76,6 +67,20 @@ class BaseFileWatcher(BaseComponent):
raise TypeError(f"Expected BaseFileStore, got {type(store).__name__}")
self.file_store = store
parsers = app_context.components.get(ComponentEnum.FILE_PARSER, {})
for parser in parsers.values():
if isinstance(parser, BaseFileParser):
for suffix in parser.suffixes:
self._suffix_to_parser[suffix] = parser
if self._default_parser_name and self._default_parser_name in parsers:
parser = parsers[self._default_parser_name]
if isinstance(parser, BaseFileParser):
self._default_parser = parser
if not self._suffix_to_parser and not self._default_parser:
self.logger.warning("No file parsers registered")
async def _initialize_and_watch():
if self.rebuild_index_on_start and self.file_store:
await self.file_store.clear_all()
@ -100,18 +105,10 @@ class BaseFileWatcher(BaseComponent):
self._watch_task = None
self._stop_event.clear()
self.file_store = None
self._suffix_to_parser.clear()
self._default_parser = None
self.logger.info("Stopped watching")
def watch_filter(self, _change: Change, path: str) -> bool:
"""Filter files by suffix. Returns True if no filters configured."""
if not self.suffix_filters:
return True
for suffix in self.suffix_filters:
if path.endswith("." + suffix.strip(".")):
return True
return False
async def _scan_existing_files(self):
"""Scan existing files and add them as Change.added."""
if not self.file_store:
@ -127,17 +124,12 @@ class BaseFileWatcher(BaseComponent):
continue
if watch_path.is_file():
if self.watch_filter(Change.added, str(watch_path)):
existing_files.add((Change.added, str(watch_path)))
existing_files.add((Change.added, str(watch_path)))
elif watch_path.is_dir():
if self.recursive:
for file_path in watch_path.rglob("*"):
if file_path.is_file() and self.watch_filter(Change.added, str(file_path)):
existing_files.add((Change.added, str(file_path)))
else:
for file_path in watch_path.iterdir():
if file_path.is_file() and self.watch_filter(Change.added, str(file_path)):
existing_files.add((Change.added, str(file_path)))
iterator = watch_path.rglob("*") if self.recursive else watch_path.iterdir()
for file_path in iterator:
if file_path.is_file():
existing_files.add((Change.added, str(file_path)))
if existing_files:
self.logger.info(f"[SCAN_ON_START] Found {len(existing_files)} existing files")
@ -180,7 +172,6 @@ class BaseFileWatcher(BaseComponent):
self.logger.info(f"Starting watch on: {valid_paths}")
async for changes in awatch(
*valid_paths,
watch_filter=self.watch_filter,
recursive=self.recursive,
debounce=self.debounce,
poll_delay_ms=self.poll_delay_ms,

View file

@ -0,0 +1,48 @@
"""Full file watcher for all supported file types."""
from pathlib import Path
from watchfiles import Change
from .base_file_watcher import BaseFileWatcher
from ..component_registry import R
@R.register("full")
class FullFileWatcher(BaseFileWatcher):
"""Watches all supported file types and delegates parsing to registered parsers."""
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes by delegating to appropriate parsers."""
if not self.file_store:
self.logger.warning("File store not initialized, skipping changes")
return
for change_type, path in changes:
try:
if change_type in (Change.added, Change.modified):
suffix = Path(path).suffix.lower()
parser = self._suffix_to_parser.get(suffix, self._default_parser)
if not parser:
self.logger.debug(f"No parser available for file type {suffix}: {path}, skipping")
continue
file_meta, chunks = await parser.parse(path)
await self.file_store.upsert_file(file_meta, chunks)
self.logger.info(f"Upserted {len(chunks)} chunks for {file_meta.path}")
elif change_type == Change.deleted:
await self.file_store.delete_file(path)
self.logger.info(f"Deleted {path}")
else:
self.logger.warning(f"Unknown change type: {change_type}")
self.logger.info(f"File {change_type} changed: {path}")
except FileNotFoundError:
self.logger.warning(f"File not found: {path}, skipping")
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)

View file

@ -0,0 +1,53 @@
"""Lightweight file watcher for Markdown files only."""
from pathlib import Path
from watchfiles import Change
from .base_file_watcher import BaseFileWatcher
from ..component_registry import R
@R.register("light")
class LightFileWatcher(BaseFileWatcher):
"""Watches only Markdown files and delegates parsing to registered parsers."""
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes by filtering for Markdown files only."""
if not self.file_store:
self.logger.warning("File store not initialized, skipping changes")
return
# Filter changes to only include Markdown files
md_changes = {
(change_type, path) for change_type, path in changes if Path(path).suffix.lower() in [".md", ".markdown"]
}
for change_type, path in md_changes:
try:
if change_type in (Change.added, Change.modified):
# Use Markdown parser for Markdown files
parser = self._suffix_to_parser.get(".md", self._default_parser)
if not parser:
self.logger.warning(f"No parser available for Markdown file: {path}")
continue
file_meta, chunks = await parser.parse(path)
await self.file_store.upsert_file(file_meta, chunks)
self.logger.info(f"Upserted {len(chunks)} chunks for {file_meta.path}")
elif change_type == Change.deleted:
await self.file_store.delete_file(path)
self.logger.info(f"Deleted {path}")
else:
self.logger.warning(f"Unknown change type: {change_type}")
self.logger.info(f"Markdown file {change_type} changed: {path}")
except FileNotFoundError:
self.logger.warning(f"File not found: {path}, skipping")
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)

View file

@ -1,86 +0,0 @@
"""Markdown file watcher for synchronization."""
import asyncio
from pathlib import Path
from watchfiles import Change
from .base_file_watcher import BaseFileWatcher
from ..component_registry import R
from ...schema import FileMetadata
from ...utils import hash_text, chunk_markdown
@R.register("md")
class MdFileWatcher(BaseFileWatcher):
"""Markdown file watcher that syncs .md files to memory store."""
def __init__(self, encoding: str = "utf-8", **kwargs):
"""Initialize Markdown file watcher.
Args:
encoding: File encoding.
"""
super().__init__(**kwargs)
self.encoding = encoding
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes with full synchronization."""
if not self.file_store:
self.logger.warning("File store not initialized, skipping changes")
return
for change_type, path in changes:
try:
if change_type in [Change.added, Change.modified]:
file_meta = await self._build_file_metadata(path)
chunks = (
chunk_markdown(
file_meta.content,
file_meta.path,
self.chunk_tokens,
self.chunk_overlap,
)
or []
)
if chunks:
chunks = await self.file_store.get_chunk_embeddings(chunks)
file_meta.chunk_count = len(chunks)
await self.file_store.delete_file(file_meta.path)
self.logger.info(f"delete_file {file_meta.path}")
await self.file_store.upsert_file(file_meta, chunks)
self.logger.info(f"Upserted {file_meta.chunk_count} chunks for {file_meta.path}")
elif change_type == Change.deleted:
await self.file_store.delete_file(path)
self.logger.info(f"Deleted {path}")
else:
self.logger.warning(f"Unknown change type: {change_type}")
self.logger.info(f"File {change_type} changed: {path}")
except FileNotFoundError:
self.logger.warning(f"File not found: {path}, skipping")
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 _build_file_metadata(self, path: str) -> FileMetadata:
"""Build FileMetadata from file path."""
file_path = Path(path)
def _read_file_sync():
return file_path.stat(), file_path.read_text(encoding=self.encoding)
stat, content = await asyncio.to_thread(_read_file_sync)
return FileMetadata(
hash=hash_text(content),
mtime_ms=stat.st_mtime * 1000,
size=stat.st_size,
path=str(file_path.absolute()),
content=content,
)

View file

@ -1,4 +1,5 @@
"""Abstract base class for service implementations."""
from abc import abstractmethod
from typing import TYPE_CHECKING

View file

@ -27,7 +27,8 @@ class HttpService(BaseService):
server-sent events (SSE) for real-time streaming.
"""
from ...application import Application
# Removed the circular import - using string annotation instead
# from ...application import Application
def __init__(self, host: str = REME_DEFAULT_HOST, port: int = REME_DEFAULT_PORT, **kwargs):
"""Initialize the HTTP service.
@ -97,7 +98,7 @@ class HttpService(BaseService):
else:
self._add_job(job)
def build_service(self, app: Application) -> None:
def build_service(self, app: "Application") -> None:
"""Build the FastAPI application with CORS middleware.
Args:
@ -129,7 +130,7 @@ class HttpService(BaseService):
)
self.service.post("/health")(lambda: {"status": "healthy"})
def start_service(self, app: Application) -> None:
def start_service(self, app: "Application") -> None:
"""Start the HTTP server.
Args:

View file

@ -6,7 +6,7 @@ reme version
reme vault="My Vault"
# daily
# daily
daily:path
reme daily:xxx
@ -28,12 +28,12 @@ reme list file/path
reme search query="search term" limit=10 tag="[]" score=0.1 copy=true
# property
reme property:read
reme property:read
reme property:update file="My Note" status=done xx=xxx
reme property:delete keys="[xxxx, xxxx]"
# 全局所有标签
reme tags
reme tags
# show link
reme backlinks file="My Note"

View file

@ -3,7 +3,7 @@ log_to_console: true
log_to_file: false
service:
backend: cmd
backend: http
jobs:
- backend: base
@ -22,18 +22,19 @@ jobs:
backend: xxx
components:
as_llms:
as_llm:
default:
backend: openai
model_name: qwen3.6-plus
as_llm_formatters:
as_llm_formatter:
default:
backend: openai
embedding_models:
embedding_model:
default:
backend: openai
model_name: text-embedding-v3
dimensions: 1024
use_dimensions: false
enable_cache: true
@ -41,15 +42,23 @@ components:
max_cache_size: 2000
max_input_length: 8192
file_stores:
file_parser:
md:
backend: md
default:
backend: chroma
backend: default
file_store:
default:
backend: local
embedding_model: default
store_name: "reme"
db_path: ".reme/store"
file_watchers:
file_watcher:
default:
backend: full
file_store: default
suffix_filters: [ ".md" ]
recursive: false
default_parser: default
watch_paths: ["./test_data"]
recursive: true

View file

@ -24,6 +24,8 @@ class ComponentEnum(str, Enum):
EMBEDDING_MODEL = "embedding_model"
FILE_PARSER = "file_parser"
FILE_STORE = "file_store"
FILE_WATCHER = "file_watcher"

View file

@ -40,10 +40,10 @@ class FileIO:
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,
self,
file_path: str,
start_line: int | None = None,
end_line: int | None = None,
) -> ToolResponse:
"""Read a file. Relative paths resolve from WORKING_DIR.
@ -138,7 +138,7 @@ class FileIO:
)
# Extract selected lines
selected_content = "\n".join(all_lines[s - 1: e])
selected_content = "\n".join(all_lines[s - 1 : e])
# Apply smart truncation (consistent with shell output format)
text = truncate_text_output(
@ -154,14 +154,13 @@ class FileIO:
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."
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
@ -180,9 +179,9 @@ class FileIO:
)
async def write_file(
self,
file_path: str,
content: str,
self,
file_path: str,
content: str,
) -> ToolResponse:
"""Create or overwrite a file. Relative paths resolve from working_dir.
@ -227,10 +226,10 @@ class FileIO:
# pylint: disable=too-many-return-statements
async def edit_file(
self,
file_path: str,
old_text: str,
new_text: str,
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.
@ -315,9 +314,9 @@ class FileIO:
)
async def append_file(
self,
file_path: str,
content: str,
self,
file_path: str,
content: str,
) -> ToolResponse:
"""Append content to the end of a file. Relative paths resolve from
working_dir.

View file

@ -12,12 +12,12 @@ from ..constants import (
def _truncate_fresh(
text: str,
start_line: int,
total_lines: int,
max_bytes: int,
file_path: str | None,
encoding: str,
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.
@ -66,21 +66,20 @@ def _truncate_fresh(
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."
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,
text: str,
max_bytes: int,
encoding: str,
) -> str:
"""Re-truncate text that was previously truncated (contains TRUNCATION_NOTICE_MARKER).
@ -133,12 +132,12 @@ def _retruncate(
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",
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.

View file

@ -5,6 +5,7 @@ import json
from ..component import R
from ..component.base_step import BaseStep
from ..enumeration import ComponentEnum
from ..schema import SearchFilter
@R.register("memory_search")
@ -33,17 +34,26 @@ class MemorySearch(BaseStep):
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}"
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}"
filter_paths: list[str] | None = self.context.get("paths") or None
filter_tags: list[str] | None = self.context.get("tags") or None
exclude_paths: list[str] | None = self.context.get("exclude_paths") or None
search_filter = None
if filter_paths or filter_tags or exclude_paths:
search_filter = SearchFilter(paths=filter_paths, tags=filter_tags, exclude_paths=exclude_paths)
# Use hybrid_search from file_store
results = await self.file_store.hybrid_search(
query=query,
limit=max_results,
vector_weight=self.vector_weight,
candidate_multiplier=self.candidate_multiplier,
search_filter=search_filter,
)
# Filter by min_score

View file

@ -8,6 +8,7 @@ from agentscope.agent import ReActAgent
from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from agentscope.tool import Toolkit
from loguru import logger
from ..component import BaseStep
from ..schema import AsMsgStat, AsBlockStat
@ -17,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.
@ -51,7 +52,6 @@ class Summarizer(BaseStep):
self.add_thinking_block: bool = add_thinking_block
self._as_token_counter: HuggingFaceTokenCounter | None = as_token_counter
def _get_current_datetime(self) -> datetime.datetime:
"""Get current datetime with timezone, fallback to local time if timezone is invalid."""
if self.timezone:
@ -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.

View file

@ -20,20 +20,19 @@ from .utils import run_coro_safely
class ReMe(Application):
"""ReMe memory management application."""
memory_path = working_path / "memory"
memory_path.mkdir(parents=True, exist_ok=True)
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.
@ -87,6 +86,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
try:
search_step = MemorySearch()
self.logger.info(f"Running memory search with {query} {max_results} {min_score}")
@ -95,26 +95,28 @@ 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 ""
class ReMeLight(ReMe):

View file

@ -7,6 +7,7 @@ from .file_chunk import FileChunk
from .file_metadata import FileMetadata
from .request import Request
from .response import Response
from .search_filter import SearchFilter
from .stream_chunk import StreamChunk
__all__ = [
@ -20,5 +21,6 @@ __all__ = [
"FileMetadata",
"Request",
"Response",
"SearchFilter",
"StreamChunk",
]

View file

@ -0,0 +1,36 @@
"""Search filter schema for constraining search results."""
from pydantic import BaseModel, Field
class SearchFilter(BaseModel):
"""Filter conditions for search operations.
All specified conditions are combined with AND logic.
Within paths/exclude_paths, items are combined with OR logic.
Within tags, items are combined with AND logic (all must match).
"""
paths: list[str] | None = Field(
default=None,
description="Include only chunks whose path starts with any of these prefixes",
)
tags: list[str] | None = Field(default=None, description="Include only chunks containing ALL specified tags")
exclude_paths: list[str] | None = Field(
default=None,
description="Exclude chunks whose path starts with any of these prefixes",
)
def is_empty(self) -> bool:
return not self.paths and not self.tags and not self.exclude_paths
def match(self, path: str, metadata: dict | None = None) -> bool:
if self.paths and not any(path.startswith(p) for p in self.paths):
return False
if self.exclude_paths and any(path.startswith(p) for p in self.exclude_paths):
return False
if self.tags:
chunk_tags = set((metadata or {}).get("tags", []))
if not all(t in chunk_tags for t in self.tags):
return False
return True

201
tests/test_reme2.py Normal file
View file

@ -0,0 +1,201 @@
import asyncio
import os
import tempfile
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
from reme2.config.config_parser import _load_yaml
from reme2.reme import ReMe
async def test_full_watcher():
"""测试full watcher模式 - 处理所有文件类型"""
print("\n=== 测试 Full Watcher 模式 ===")
config = _load_yaml("paw")
# 设置API密钥
config["components"]["embedding_model"]["default"]["api_key"] = os.environ["EMBEDDING_API_KEY"]
config["components"]["embedding_model"]["default"]["base_url"] = os.environ["EMBEDDING_BASE_URL"]
config["components"]["as_llm"]["default"]["api_key"] = os.environ["LLM_API_KEY"]
config["components"]["as_llm"]["default"]["base_url"] = os.environ["LLM_BASE_URL"]
config["components"]["as_llm"]["default"]["model_name"] = os.environ["LLM_MODEL_NAME"]
# 创建临时目录用于测试
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# 创建测试文件
md_file = temp_path / "test.md"
txt_file = temp_path / "test.txt"
py_file = temp_path / "test.py"
md_content = """# Test Markdown File
This is a test markdown file for full watcher.
## Section
Some content here about Python programming.
"""
txt_content = """Plain text file for testing full watcher.
Contains some interesting content about machine learning AI."""
py_content = '''"""
Test Python file for full watcher.
Demonstrates Python programming concepts.
"""
def hello_world():
"""A simple function."""
print("Hello, world!")
# Machine learning related code
return True
'''
md_file.write_text(md_content)
txt_file.write_text(txt_content)
py_file.write_text(py_content)
# 更新配置使用临时目录
config["components"]["file_watcher"]["default"]["watch_paths"] = [str(temp_path)]
config["components"]["file_watcher"]["default"]["recursive"] = False
reme = ReMe(**config)
# Start components (embedding, file_store, file_parser, file_watcher)
await reme.start()
# Wait for watcher to scan existing files
await asyncio.sleep(3)
# Test: list indexed files
from reme2.enumeration import ComponentEnum
store = reme.context.components[ComponentEnum.FILE_STORE]["default"]
files = await store.list_files()
print(f"\n=== Indexed files by Full Watcher ({len(files)}) ===")
for f in files:
meta = await store.get_file_metadata(f)
chunks = await store.get_file_chunks(f)
print(f" {f}{meta.chunk_count} chunks, metadata={meta.metadata}")
# Test: keyword search
results = await store.keyword_search("Python programming", limit=5)
print(f"\n=== Keyword search 'Python programming' ({len(results)} results) ===")
for r in results:
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line}{r.text[:80]}")
# Test: hybrid search
results = await store.hybrid_search("machine learning AI", limit=5)
print(f"\n=== Hybrid search 'machine learning AI' ({len(results)} results) ===")
for r in results:
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line}{r.text[:80]}")
await reme.close()
print("\n=== Full Watcher 测试完成 ===")
async def test_light_watcher():
"""测试light watcher模式 - 只处理markdown文件"""
print("\n=== 测试 Light Watcher 模式 ===")
config = _load_yaml("paw")
# 设置API密钥
config["components"]["embedding_model"]["default"]["api_key"] = os.environ["EMBEDDING_API_KEY"]
config["components"]["embedding_model"]["default"]["base_url"] = os.environ["EMBEDDING_BASE_URL"]
config["components"]["as_llm"]["default"]["api_key"] = os.environ["LLM_API_KEY"]
config["components"]["as_llm"]["default"]["base_url"] = os.environ["LLM_BASE_URL"]
config["components"]["as_llm"]["default"]["model_name"] = os.environ["LLM_MODEL_NAME"]
# 更改配置使用light watcher
config["components"]["file_watcher"]["default"]["backend"] = "light"
# 创建临时目录用于测试
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# 创建测试文件 - 包含markdown和其他类型文件
md_file = temp_path / "light_test.md"
txt_file = temp_path / "light_test.txt"
py_file = temp_path / "light_test.py"
md_content = """# Light Test Markdown File
This is a test markdown file for light watcher.
## Section
Only markdown files should be processed by light watcher.
"""
txt_content = "This text file should be ignored by light watcher."
py_content = "# This Python file should be ignored by light watcher"
md_file.write_text(md_content)
txt_file.write_text(txt_content)
py_file.write_text(py_content)
# 更新配置使用临时目录
config["components"]["file_watcher"]["default"]["watch_paths"] = [str(temp_path)]
config["components"]["file_watcher"]["default"]["recursive"] = False
reme = ReMe(**config)
# Start components
await reme.start()
# Wait for watcher to scan existing files
await asyncio.sleep(3)
# Test: list indexed files - should only contain markdown files
from reme2.enumeration import ComponentEnum
store = reme.context.components[ComponentEnum.FILE_STORE]["default"]
files = await store.list_files()
print(f"\n=== Indexed files by Light Watcher ({len(files)}) ===")
for f in files:
# 验证只有markdown文件被索引
file_path = Path(f)
if file_path.suffix.lower() in [".md", ".markdown"]:
meta = await store.get_file_metadata(f)
chunks = await store.get_file_chunks(f)
print(f" {f}{meta.chunk_count} chunks, metadata={meta.metadata}")
else:
print(f" ERROR: Non-markdown file indexed: {f}")
print(f"\nExpected: Only markdown files should be indexed. Actually indexed: {len(files)} files")
# Test: search
results = await store.keyword_search("light watcher", limit=5)
print(f"\n=== Keyword search 'light watcher' ({len(results)} results) ===")
for r in results:
print(f" [{r.score:.2f}] {r.path}:{r.start_line}-{r.end_line}{r.text[:80]}")
await reme.close()
print("\n=== Light Watcher 测试完成 ===")
async def main():
"""主函数,运行所有测试"""
print("开始测试ReMe系统的Full和Light Watcher模式")
try:
await test_full_watcher()
await test_light_watcher()
print("\n✅ 所有测试完成!")
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())