mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
197 lines
5.6 KiB
Python
197 lines
5.6 KiB
Python
"""Memory-directory scanning primitives.
|
|
|
|
Implementation notes: ``memdir/memoryScan.ts`` (95 lines). This module scans topic
|
|
memory files, parses their frontmatter header, and formats a manifest for
|
|
recall/extraction follow-up steps.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from .memdir import ENTRYPOINT_NAME
|
|
from .memory_types import MemoryType, parse_memory_type
|
|
|
|
MAX_MEMORY_FILES = 200
|
|
FRONTMATTER_MAX_LINES = 30
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MemoryHeader:
|
|
filename: str
|
|
file_path: Path
|
|
mtime_ms: float
|
|
description: Optional[str]
|
|
memory_type: Optional[MemoryType]
|
|
|
|
@property
|
|
def filePath(self) -> str:
|
|
"""legacy-compatible camelCase alias."""
|
|
|
|
return str(self.file_path)
|
|
|
|
@property
|
|
def mtimeMs(self) -> float:
|
|
"""legacy-compatible camelCase alias."""
|
|
|
|
return self.mtime_ms
|
|
|
|
@property
|
|
def type(self) -> Optional[MemoryType]:
|
|
"""legacy-compatible field name."""
|
|
|
|
return self.memory_type
|
|
|
|
|
|
def scan_memory_files(memory_dir: str | Path) -> list[MemoryHeader]:
|
|
"""Scan ``memory_dir`` for topic ``.md`` files, newest first.
|
|
|
|
Any directory-level error returns ``[]`` like OpenSpace. Per-file errors are
|
|
ignored via the same all-settled behavior as ``Promise.allSettled``.
|
|
"""
|
|
|
|
root = Path(memory_dir).expanduser()
|
|
try:
|
|
entries = list(root.rglob("*.md"))
|
|
except OSError:
|
|
return []
|
|
|
|
headers: list[MemoryHeader] = []
|
|
for entry in entries:
|
|
try:
|
|
if (
|
|
entry.name == ENTRYPOINT_NAME
|
|
or not entry.is_file()
|
|
or _is_daily_log_path(root, entry)
|
|
):
|
|
continue
|
|
header = _read_memory_header(root, entry)
|
|
except OSError:
|
|
continue
|
|
if header is not None:
|
|
headers.append(header)
|
|
|
|
headers.sort(key=lambda item: item.mtime_ms, reverse=True)
|
|
return headers[:MAX_MEMORY_FILES]
|
|
|
|
|
|
def _is_daily_log_path(root: Path, path: Path) -> bool:
|
|
"""Return True for ``logs/YYYY/MM/*.md`` raw daily-log files."""
|
|
|
|
try:
|
|
parts = path.relative_to(root).parts
|
|
except ValueError:
|
|
return False
|
|
return len(parts) >= 4 and parts[0] == "logs"
|
|
|
|
|
|
def format_memory_manifest(memories: list[MemoryHeader]) -> str:
|
|
"""Format memory headers as OpenSpace's one-line text manifest."""
|
|
|
|
lines: list[str] = []
|
|
for memory in memories:
|
|
tag = f"[{memory.memory_type}] " if memory.memory_type else ""
|
|
timestamp = datetime.fromtimestamp(
|
|
memory.mtime_ms / 1000,
|
|
tz=timezone.utc,
|
|
).isoformat().replace("+00:00", "Z")
|
|
if memory.description:
|
|
lines.append(f"- {tag}{memory.filename} ({timestamp}): {memory.description}")
|
|
else:
|
|
lines.append(f"- {tag}{memory.filename} ({timestamp})")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _read_memory_header(root: Path, path: Path) -> Optional[MemoryHeader]:
|
|
stat = path.stat()
|
|
try:
|
|
content = _read_first_lines(path, FRONTMATTER_MAX_LINES)
|
|
except UnicodeDecodeError:
|
|
return None
|
|
frontmatter = _parse_frontmatter(content)
|
|
try:
|
|
filename = path.relative_to(root).as_posix()
|
|
except ValueError:
|
|
filename = path.name
|
|
return MemoryHeader(
|
|
filename=filename,
|
|
file_path=path.resolve(),
|
|
mtime_ms=stat.st_mtime * 1000,
|
|
description=_coerce_description(frontmatter.get("description")),
|
|
memory_type=parse_memory_type(frontmatter.get("type")),
|
|
)
|
|
|
|
|
|
def _read_first_lines(path: Path, limit: int) -> str:
|
|
lines: list[str] = []
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
for index, line in enumerate(handle):
|
|
if index >= limit:
|
|
break
|
|
lines.append(line)
|
|
return "".join(lines)
|
|
|
|
|
|
def _parse_frontmatter(content: str) -> dict[str, object]:
|
|
normalized = content.replace("\r\n", "\n")
|
|
if not normalized.startswith("---\n"):
|
|
return {}
|
|
end = normalized.find("\n---", 4)
|
|
if end == -1:
|
|
return {}
|
|
raw = normalized[4:end]
|
|
parsed: dict[str, object] = {}
|
|
current_key: Optional[str] = None
|
|
list_values: list[str] = []
|
|
|
|
def flush_list() -> None:
|
|
nonlocal current_key, list_values
|
|
if current_key is not None and list_values:
|
|
parsed[current_key] = list(list_values)
|
|
current_key = None
|
|
list_values = []
|
|
|
|
for line in raw.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if current_key is not None and stripped.startswith("-"):
|
|
list_values.append(_strip_yaml_scalar(stripped[1:].strip()))
|
|
continue
|
|
flush_list()
|
|
if ":" not in line:
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if not value:
|
|
current_key = key
|
|
list_values = []
|
|
continue
|
|
parsed[key] = _strip_yaml_scalar(value)
|
|
|
|
flush_list()
|
|
return parsed
|
|
|
|
|
|
def _strip_yaml_scalar(value: str) -> str:
|
|
value = value.strip()
|
|
if " #" in value:
|
|
value = value.split(" #", 1)[0].rstrip()
|
|
if (value.startswith('"') and value.endswith('"')) or (
|
|
value.startswith("'") and value.endswith("'")
|
|
):
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def _coerce_description(value: object) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (str, int, float, bool)):
|
|
text = str(value).strip()
|
|
return text or None
|
|
return None
|