Files
2026-07-01 17:56:31 +08:00

193 lines
7.6 KiB
Python

"""Markdown-backed storage for durable global/project memory."""
from __future__ import annotations
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
from loguru import logger
_STRUCTURED_BEGIN = "<!-- OPC_STRUCTURED_MEMORY:BEGIN -->"
_STRUCTURED_END = "<!-- OPC_STRUCTURED_MEMORY:END -->"
_EMPTY_SECRETARY = {
"memory_notes": [],
"authorization_rules": [],
"workspace_guardrails": [],
"skill_injection_rules": [],
}
class MarkdownMemoryStore:
"""Persists pure Markdown memory at global and project scopes."""
def __init__(self, opc_home: Path) -> None:
self.opc_home = Path(opc_home)
self.global_memory_dir = self.opc_home / "memory"
self.project_memory_dir = self.global_memory_dir / "projects"
self.global_memory_dir.mkdir(parents=True, exist_ok=True)
self.project_memory_dir.mkdir(parents=True, exist_ok=True)
self.migrate_legacy_memory()
def memory_path(self, project_id: str | None = None) -> Path:
project = str(project_id or "").strip()
if project:
return self.project_memory_dir / f"{project}.md"
return self.global_memory_dir / "global.md"
def legacy_memory_path(self, project_id: str | None = None) -> Path:
project = str(project_id or "").strip()
if project:
return self.opc_home / "projects" / project / "MEMORY.md"
return self.global_memory_dir / "MEMORY.md"
def history_path(self, project_id: str | None = None) -> Path:
project = str(project_id or "").strip()
if project:
return self.opc_home / "projects" / project / "HISTORY.md"
return self.global_memory_dir / "HISTORY.md"
def load_raw_text(self, project_id: str | None = None) -> str:
path = self.memory_path(project_id)
if path.exists():
return path.read_text(encoding="utf-8")
return ""
def load_visible_text(self, project_id: str | None = None) -> str:
return self._strip_structured_block(self.load_raw_text(project_id)).strip()
def save_visible_text(self, content: str, project_id: str | None = None) -> None:
self._write_file(project_id, str(content).strip())
def append_visible_entry(self, entry: str, project_id: str | None = None) -> None:
existing = self.load_visible_text(project_id)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
updated = f"{existing}\n\n## [{timestamp}]\n{entry}".strip()
self.save_visible_text(updated, project_id)
def ensure_memory_file(self, project_id: str | None = None, heading: str | None = None) -> Path:
path = self.memory_path(project_id)
if not path.exists():
title = heading or (f"# Project Memory ({project_id})" if project_id else "# Global Memory")
self._write_file(project_id, title)
return path
def delete_project(self, project_id: str) -> None:
path = self.memory_path(project_id)
if path.exists():
path.unlink()
for legacy in (
self.legacy_memory_path(project_id),
self.history_path(project_id),
self.opc_home / "projects" / project_id / "project_profile.yaml",
self.opc_home / "projects" / project_id / "secretary_profile.yaml",
):
self._safe_unlink(legacy)
def migrate_legacy_memory(self) -> None:
self._migrate_one(None)
projects_dir = self.opc_home / "projects"
if projects_dir.is_dir():
for entry in sorted(projects_dir.iterdir()):
if entry.is_dir():
self._migrate_one(entry.name)
self._safe_unlink(self.history_path(None))
profiles_dir = self.opc_home / "profiles"
if profiles_dir.exists():
shutil.rmtree(profiles_dir, ignore_errors=True)
# Compatibility surface for old preference/secretary managers. These
# managers are now disabled by default; returning empty structures keeps
# existing callers harmless without reintroducing hidden YAML memory.
def load_scope_data(self, project_id: str | None = None) -> dict[str, Any]:
_ = project_id
return {}
def save_scope_data(self, data: dict[str, Any], project_id: str | None = None) -> None:
_ = (data, project_id)
def load_profile(self, project_id: str | None = None) -> dict[str, Any]:
_ = project_id
return {}
def save_profile(self, profile: dict[str, Any], project_id: str | None = None) -> None:
_ = (profile, project_id)
def load_secretary(self, project_id: str | None = None) -> dict[str, Any]:
_ = project_id
return {key: list(value) for key, value in _EMPTY_SECRETARY.items()}
def save_secretary(self, secretary: dict[str, Any], project_id: str | None = None) -> None:
_ = (secretary, project_id)
def _migrate_one(self, project_id: str | None) -> None:
canonical = self.memory_path(project_id)
legacy = self.legacy_memory_path(project_id)
legacy_visible = self._read_visible_file(legacy)
if legacy_visible:
existing = self._strip_structured_block(
canonical.read_text(encoding="utf-8") if canonical.exists() else ""
)
merged = self._merge_markdown(existing, legacy_visible)
if merged != existing.strip():
self._write_file(project_id, merged)
self._safe_unlink(legacy)
self._safe_unlink(self.history_path(project_id))
if project_id:
self._safe_unlink(self.opc_home / "projects" / project_id / "project_profile.yaml")
self._safe_unlink(self.opc_home / "projects" / project_id / "secretary_profile.yaml")
if canonical.exists():
visible = self._strip_structured_block(canonical.read_text(encoding="utf-8")).strip()
if visible != canonical.read_text(encoding="utf-8").strip():
self._write_file(project_id, visible)
def _write_file(self, project_id: str | None, visible: str) -> None:
path = self.memory_path(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
cleaned = self._strip_structured_block(str(visible or "")).strip()
path.write_text((cleaned + "\n") if cleaned else "", encoding="utf-8")
@staticmethod
def _read_visible_file(path: Path) -> str:
if not path.exists():
return ""
try:
return MarkdownMemoryStore._strip_structured_block(path.read_text(encoding="utf-8")).strip()
except Exception as exc:
logger.warning(f"Failed to read legacy memory file {path}: {exc}")
return ""
@staticmethod
def _strip_structured_block(raw: str) -> str:
if not str(raw or "").strip():
return ""
pattern = re.compile(
rf"{re.escape(_STRUCTURED_BEGIN)}\s*```yaml\s*.*?\s*```\s*{re.escape(_STRUCTURED_END)}",
re.DOTALL,
)
return pattern.sub("", raw).strip()
@staticmethod
def _merge_markdown(existing: str, incoming: str) -> str:
current = str(existing or "").strip()
addition = str(incoming or "").strip()
if not addition:
return current
if not current:
return addition
if addition in current:
return current
return f"{current}\n\n{addition}".strip()
@staticmethod
def _safe_unlink(path: Path) -> None:
try:
if path.exists() and path.is_file():
path.unlink()
except Exception as exc:
logger.debug(f"Failed to delete legacy memory file {path}: {exc}")