* feat(memory): implement Tier 2 structural organization Add four independent modules for memory structural organization: - H-MEM hierarchical directory routing (hierarchy.py) - A-MEM semantic linking via BM25 (semantic_links.py) - Auto-compression pipeline via TF-IDF (compression.py) - FTS5 full-text search index (search_index.py) Integration: - 4 feature flags in MemoryConfig (default: disabled) - MemoryEntry extended with category + compression_level fields - persistent.py wired with hierarchy routing, FTS5 search, link expansion - lifecycle.py triggers compression in GC cycle - remember_tool.py attaches related links in recall results - FTS5 CJK bigram+unigram indexing for better Chinese recall - Lazy auto-rebuild on first FTS5 search Testing: - 163 unit tests + 9 integration tests + 23 benchmarks (all passing) - Benchmark evaluated on 200 real production memories + 50 labeled queries - Compression retention: daily 91.4%, digest 97.1% - Hierarchy routing: 93.3% P@5 parity with full scan Closes #732 (Tier 2 scope) Signed-off-by: shadowinlife <shadowinlife@gmail.com> Co-Authored-By: Qoder <noreply@qoder.com> AI-Contributed/Feature: 1582/1871 AI-Contributed/UT: 2123/2159 * feat(memory): add VT_MEMORY=off|on|full business-friendly preset Simplify memory configuration from 7 individual boolean flags to a single business-friendly environment variable: - off: all features disabled (default, backward compatible) - on: Tier 1 quality + decay + garbage collection - full: Tier 1 + Tier 2 hierarchy + links + compression + FTS Individual VT_MEMORY_* flags still work as explicit overrides with higher priority than the preset baseline. Also adds a dependency-conflict warning in lifecycle.py when compression is enabled without GC. Signed-off-by: shadowinlife <shadowinlife@gmail.com> AI-Contributed/Feature: 0/138 AI-Contributed/UT: 0/0 * docs: add Memory Tier 2 and VT_MEMORY preset to CHANGELOG Document the new structural organization features (hierarchy, semantic links, compression, FTS5) and the business-friendly VT_MEMORY=off|on|full configuration preset. Signed-off-by: shadowinlife <shadowinlife@gmail.com> AI-Contributed/Feature: 0/15 AI-Contributed/UT: 0/0 --------- Co-authored-by: Qoder <noreply@qoder.com>
629 lines
24 KiB
Python
629 lines
24 KiB
Python
"""PersistentMemory: file-based cross-session memory, zero external dependencies."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import math
|
|
import re
|
|
import sys
|
|
import time as _time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Generator, List, Optional
|
|
|
|
from src.agent.frontmatter import parse_frontmatter as _parse_frontmatter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MEMORY_BASE = Path.home() / ".vibe-trading" / "memory"
|
|
MAX_INDEX_LINES = 200
|
|
MAX_ENTRY_CHARS = 8000
|
|
MAX_RESULTS = 5
|
|
METADATA_WEIGHT = 2.0
|
|
MEMORY_TYPES = ("user", "feedback", "project", "reference")
|
|
|
|
_LOCK_TIMEOUT_S = 5.0
|
|
|
|
# Sliding window for content deduplication (seconds).
|
|
# Catches rapid-fire duplicates from retry loops or parallel agent calls.
|
|
DEDUP_WINDOW_SECONDS = 30.0
|
|
|
|
|
|
def content_hash(name: str, description: str, content: str = "") -> str:
|
|
"""Generate deterministic hash for deduplication."""
|
|
payload = f"{name.strip().lower()}|{description.strip().lower()}|{content.strip().lower()}"
|
|
return hashlib.sha256(payload.encode()).hexdigest()[:12]
|
|
|
|
|
|
@contextmanager
|
|
def memory_lock(memory_dir: Path) -> Generator[bool, None, None]:
|
|
"""Acquire exclusive file lock; yields True if acquired, False on timeout."""
|
|
if sys.platform == "win32":
|
|
yield True
|
|
return
|
|
import fcntl
|
|
|
|
lock_path = memory_dir / ".lock"
|
|
lock_path.touch(exist_ok=True)
|
|
fd = None
|
|
try:
|
|
fd = open(lock_path, "w") # noqa: SIM115
|
|
deadline = _time.monotonic() + _LOCK_TIMEOUT_S
|
|
while True:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
yield True
|
|
return
|
|
except (OSError, BlockingIOError):
|
|
if _time.monotonic() >= deadline:
|
|
logger.warning("memory_lock: timeout after %.1fs", _LOCK_TIMEOUT_S)
|
|
yield False
|
|
return
|
|
_time.sleep(0.1)
|
|
finally:
|
|
if fd:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
except OSError:
|
|
pass
|
|
fd.close()
|
|
|
|
|
|
HALF_LIFE_DAYS = 14.0
|
|
_DECAY_LAMBDA = math.log(2) / HALF_LIFE_DAYS
|
|
_ACCESS_BOOST = 0.1
|
|
|
|
|
|
def compute_importance(
|
|
quality_score: float, access_count: int, days_since_last_access: float
|
|
) -> float:
|
|
"""Compute importance via Ebbinghaus-inspired decay formula."""
|
|
from src.config.accessor import get_env_config
|
|
|
|
if not get_env_config().memory.decay_enabled:
|
|
return quality_score
|
|
retention = math.exp(-_DECAY_LAMBDA * max(0.0, days_since_last_access))
|
|
access_bonus = min(0.3, access_count * _ACCESS_BOOST)
|
|
raw = quality_score * (retention + access_bonus)
|
|
return min(1.0, max(0.0, raw))
|
|
|
|
|
|
def _is_decay_enabled() -> bool:
|
|
"""Check if importance decay is enabled via VT_MEMORY_DECAY env var."""
|
|
from src.config.accessor import get_env_config
|
|
|
|
return get_env_config().memory.decay_enabled
|
|
|
|
|
|
def _is_quality_enabled() -> bool:
|
|
"""Check if quality scoring is enabled via VT_MEMORY_QUALITY env var."""
|
|
from src.config.accessor import get_env_config
|
|
|
|
return get_env_config().memory.quality_enabled
|
|
|
|
|
|
# Script ranges for non-Latin tokenization and slug generation.
|
|
_NON_LATIN_SCRIPT_RANGES = (
|
|
"一-鿿" # CJK Unified Ideographs
|
|
"㐀-䶿" # CJK Extension A
|
|
"-" # Thai
|
|
"ؠ-ي" # Arabic letters
|
|
"א-ת" # Hebrew letters
|
|
"Ѐ-ӿ" # Cyrillic
|
|
)
|
|
|
|
_TOKEN_RE = re.compile(rf"[a-zA-Z0-9]{{3,}}|[{_NON_LATIN_SCRIPT_RANGES}]")
|
|
_SLUG_DISALLOWED_RE = re.compile(rf"[^a-z0-9_\-{_NON_LATIN_SCRIPT_RANGES}]")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MemoryEntry:
|
|
"""A single memory entry on disk."""
|
|
|
|
path: Path
|
|
title: str
|
|
description: str
|
|
memory_type: str
|
|
body: str
|
|
modified_at: float
|
|
id: str = ""
|
|
created_at: float = 0.0
|
|
updated_at: float = 0.0
|
|
keywords: tuple[str, ...] = ()
|
|
quality_score: float = 0.5
|
|
access_count: int = 0
|
|
last_accessed: float = 0.0
|
|
importance: float = 0.5
|
|
related_memories: tuple[str, ...] = ()
|
|
category: str = "" # H-MEM directory classification
|
|
compression_level: str = "raw" # raw/daily/digest
|
|
|
|
|
|
def _tokenize(text: str) -> set[str]:
|
|
"""Split text into searchable tokens (ASCII >=3 chars + non-Latin chars)."""
|
|
return set(_TOKEN_RE.findall(text.lower()))
|
|
|
|
|
|
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
_TRUNCATION_MARKER = "\n\n[truncated at {limit} chars]\n"
|
|
|
|
|
|
def _sanitize_body(content: str) -> str:
|
|
"""Strip C0/C1 control bytes from `content` while keeping ``\\n`` and ``\\t``."""
|
|
return _CONTROL_CHAR_RE.sub("", content)
|
|
|
|
|
|
def _truncate_body(content: str, limit: int = MAX_ENTRY_CHARS) -> str:
|
|
"""Clip `content` to `limit` chars, leaving room for the marker."""
|
|
if len(content) <= limit:
|
|
return content
|
|
marker = _TRUNCATION_MARKER.format(limit=limit)
|
|
head_len = max(0, limit - len(marker))
|
|
return content[:head_len] + marker
|
|
|
|
|
|
def _coerce_str(value: object, default: str = "") -> str:
|
|
"""Coerce frontmatter values to a display string."""
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, list):
|
|
return ", ".join(str(v) for v in value)
|
|
return str(value)
|
|
|
|
|
|
def _parse_timestamp(value: object, fallback: float) -> float:
|
|
"""Parse a timestamp from frontmatter. Returns epoch float."""
|
|
if value is None:
|
|
return fallback
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
if isinstance(value, str):
|
|
try:
|
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
return dt.timestamp()
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return fallback
|
|
|
|
|
|
class PersistentMemory:
|
|
"""File-based persistent memory that survives across sessions."""
|
|
|
|
def __init__(self, memory_dir: Optional[Path] = None) -> None:
|
|
self._dir = memory_dir or MEMORY_BASE
|
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
self._index_path = self._dir / "MEMORY.md"
|
|
self._snapshot: str = ""
|
|
self._recent_hashes: dict[str, float] = {} # hash -> epoch timestamp
|
|
self._load_snapshot()
|
|
|
|
def _load_snapshot(self) -> None:
|
|
"""Load index as frozen snapshot. Called once at init."""
|
|
if self._index_path.exists():
|
|
try:
|
|
text = self._index_path.read_text(encoding="utf-8")
|
|
lines = text.split("\n")[:MAX_INDEX_LINES]
|
|
self._snapshot = "\n".join(lines)
|
|
except OSError:
|
|
self._snapshot = ""
|
|
|
|
@property
|
|
def snapshot(self) -> str:
|
|
"""Frozen memory index for system prompt injection."""
|
|
return self._snapshot
|
|
|
|
def _scan_entries(self) -> List[MemoryEntry]:
|
|
"""Scan all .md files (except MEMORY.md) and parse frontmatter."""
|
|
from src.config.accessor import get_env_config
|
|
cfg = get_env_config().memory
|
|
|
|
if cfg.hierarchy_enabled:
|
|
from src.memory.hierarchy import MemoryHierarchy
|
|
hierarchy = MemoryHierarchy(self._dir)
|
|
md_files = hierarchy.scan_all()
|
|
else:
|
|
md_files = sorted(self._dir.glob("*.md"))
|
|
|
|
entries: List[MemoryEntry] = []
|
|
for path in md_files:
|
|
if path.name == "MEMORY.md":
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
continue
|
|
meta, body = _parse_frontmatter(text)
|
|
mtime = path.stat().st_mtime
|
|
raw_kw = meta.get("keywords", [])
|
|
keywords = tuple(
|
|
str(k)[:30] for k in (raw_kw if isinstance(raw_kw, list) else [])
|
|
)[:5]
|
|
raw_related = meta.get("related_memories", [])
|
|
related = tuple(
|
|
str(r)
|
|
for r in (raw_related if isinstance(raw_related, list) else [])
|
|
if isinstance(r, str)
|
|
and len(r) == 6
|
|
and all(c in "0123456789abcdef" for c in r.lower())
|
|
)
|
|
|
|
category = _coerce_str(meta.get("category"), default="")
|
|
compression_level = _coerce_str(meta.get("compression_level"), default="raw")
|
|
|
|
qs = meta.get("quality_score", 0.5)
|
|
try:
|
|
qs = max(0.0, min(1.0, float(qs)))
|
|
except (TypeError, ValueError):
|
|
qs = 0.5
|
|
|
|
ac = meta.get("access_count", 0)
|
|
try:
|
|
ac = max(0, int(ac))
|
|
except (TypeError, ValueError):
|
|
ac = 0
|
|
# Generate id if missing
|
|
entry_id = str(meta.get("id", ""))
|
|
if not entry_id or len(entry_id) != 6:
|
|
entry_id = hashlib.sha256(
|
|
f"{meta.get('name', path.stem)}{mtime}".encode()
|
|
).hexdigest()[:6]
|
|
|
|
# Timestamps and importance
|
|
created = _parse_timestamp(meta.get("created_at"), mtime)
|
|
updated = _parse_timestamp(meta.get("updated_at"), mtime)
|
|
last_acc = _parse_timestamp(meta.get("last_accessed"), mtime)
|
|
now = _time.time()
|
|
days_since = max(0.0, (now - last_acc) / 86400.0)
|
|
importance = compute_importance(qs, ac, days_since)
|
|
|
|
entries.append(
|
|
MemoryEntry(
|
|
path=path,
|
|
title=_coerce_str(meta.get("name"), default=path.stem),
|
|
description=_coerce_str(meta.get("description")),
|
|
memory_type=_coerce_str(meta.get("type"), default="project"),
|
|
body=body[:MAX_ENTRY_CHARS],
|
|
modified_at=mtime,
|
|
id=entry_id,
|
|
created_at=created,
|
|
updated_at=updated,
|
|
keywords=keywords,
|
|
quality_score=qs,
|
|
access_count=ac,
|
|
last_accessed=last_acc,
|
|
importance=importance,
|
|
related_memories=related,
|
|
category=category,
|
|
compression_level=compression_level,
|
|
)
|
|
)
|
|
return entries
|
|
|
|
def list_entries(self) -> List[MemoryEntry]:
|
|
"""Return all persisted memory entries, filename-sorted."""
|
|
return self._scan_entries()
|
|
|
|
def find(self, name: str) -> Optional[MemoryEntry]:
|
|
"""Resolve a memory by exact title, then by on-disk filename stem."""
|
|
needle = name.strip()
|
|
if not needle:
|
|
return None
|
|
entries = self._scan_entries()
|
|
for entry in entries:
|
|
if entry.title == needle:
|
|
return entry
|
|
for entry in entries:
|
|
stem = entry.path.stem
|
|
if stem == needle or stem.endswith(f"_{needle}"):
|
|
return entry
|
|
return None
|
|
|
|
def remove_entry(self, entry: MemoryEntry) -> bool:
|
|
"""Delete a resolved entry without re-scanning to find it again."""
|
|
from src.config.accessor import get_env_config
|
|
|
|
with memory_lock(self._dir) as acquired:
|
|
if not acquired:
|
|
logger.warning("remove_entry(%s): lock timeout", entry.title)
|
|
try:
|
|
entry.path.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
logger.warning("Failed to remove memory entry %s: %s", entry.path, exc)
|
|
return False
|
|
self._rebuild_index()
|
|
|
|
if get_env_config().memory.fts_index_enabled:
|
|
try:
|
|
from src.memory.search_index import get_shared_index
|
|
get_shared_index().remove_entry(entry.id)
|
|
except Exception:
|
|
logger.debug("FTS5 remove_entry failed", exc_info=True)
|
|
|
|
if get_env_config().memory.links_enabled:
|
|
try:
|
|
from src.memory.semantic_links import SemanticLinker
|
|
SemanticLinker(self._dir).remove_relations(entry.path)
|
|
except Exception:
|
|
logger.debug("Failed to remove relations for %s", entry.path, exc_info=True)
|
|
|
|
return True
|
|
|
|
def find_relevant(
|
|
self, query: str, max_results: int = MAX_RESULTS
|
|
) -> List[MemoryEntry]:
|
|
"""Keyword search across all entries, weighted by importance."""
|
|
from src.config.accessor import get_env_config
|
|
cfg = get_env_config().memory
|
|
|
|
if cfg.fts_index_enabled:
|
|
try:
|
|
from src.memory.search_index import get_shared_index
|
|
index = get_shared_index()
|
|
matches = index.search(query, max_results=max_results)
|
|
|
|
# Auto-rebuild on first empty search if entries exist on disk
|
|
all_entries = None
|
|
if not matches and not index._auto_rebuilt:
|
|
all_entries = self._scan_entries()
|
|
if all_entries:
|
|
entries_data = [
|
|
(e.id, e.title, e.description, " ".join(e.keywords), e.body)
|
|
for e in all_entries
|
|
]
|
|
index.rebuild_all(entries_data)
|
|
index._auto_rebuilt = True
|
|
matches = index.search(query, max_results=max_results)
|
|
|
|
if matches:
|
|
# Map FTS results back to full entries
|
|
if all_entries is None:
|
|
all_entries = self._scan_entries()
|
|
entry_map = {e.id: e for e in all_entries}
|
|
fts_results = [entry_map[m.entry_id] for m in matches if m.entry_id in entry_map]
|
|
if fts_results:
|
|
return fts_results[:max_results]
|
|
except Exception:
|
|
logger.debug("FTS5 search failed, falling back to scan", exc_info=True)
|
|
|
|
query_tokens = _tokenize(query)
|
|
if not query_tokens:
|
|
return []
|
|
|
|
scored: list[tuple[float, MemoryEntry]] = []
|
|
for entry in self._scan_entries():
|
|
meta_tokens = _tokenize(f"{entry.title} {entry.description}")
|
|
body_tokens = _tokenize(entry.body)
|
|
kw_tokens = _tokenize(" ".join(entry.keywords))
|
|
token_score = (
|
|
len(query_tokens & meta_tokens) * METADATA_WEIGHT
|
|
+ len(query_tokens & kw_tokens) * METADATA_WEIGHT
|
|
+ len(query_tokens & body_tokens)
|
|
)
|
|
if token_score > 0:
|
|
final_score = token_score
|
|
if _is_decay_enabled():
|
|
final_score *= 0.5 + 0.5 * entry.importance
|
|
scored.append((final_score, entry))
|
|
|
|
scored.sort(key=lambda x: (-x[0], -x[1].modified_at))
|
|
results = [entry for _, entry in scored[:max_results]]
|
|
|
|
if cfg.links_enabled and results:
|
|
try:
|
|
from src.memory.semantic_links import SemanticLinker
|
|
linker = SemanticLinker(self._dir)
|
|
all_entries = self._scan_entries()
|
|
linked_ids: set[str] = set()
|
|
for r in results:
|
|
relations = linker.load_relations(r.path)
|
|
for target_file, _score in relations:
|
|
linked_ids.add(Path(target_file).stem)
|
|
# Add linked entries not already in results
|
|
result_paths = {r.path for r in results}
|
|
for entry in all_entries:
|
|
if entry.path.stem in linked_ids and entry.path not in result_paths:
|
|
results.append(entry)
|
|
if len(results) >= max_results:
|
|
break
|
|
except Exception:
|
|
logger.debug("semantic link expansion failed", exc_info=True)
|
|
|
|
return results
|
|
|
|
def is_duplicate(self, name: str, description: str, content: str = "") -> bool:
|
|
"""Check if a memory with similar content was recently written.
|
|
|
|
Uses a 30-second sliding window to catch rapid-fire duplicates
|
|
from retry loops or parallel agent calls.
|
|
"""
|
|
new_hash = content_hash(name, description, content)
|
|
now = _time.time()
|
|
self._cleanup_expired_hashes(now)
|
|
if new_hash in self._recent_hashes:
|
|
if now - self._recent_hashes[new_hash] < DEDUP_WINDOW_SECONDS:
|
|
return True
|
|
self._recent_hashes[new_hash] = now
|
|
return False
|
|
|
|
def _cleanup_expired_hashes(self, now: float) -> None:
|
|
"""Remove hash entries older than the dedup window to bound memory use."""
|
|
threshold = now - DEDUP_WINDOW_SECONDS
|
|
expired = [h for h, ts in self._recent_hashes.items() if ts < threshold]
|
|
for h in expired:
|
|
del self._recent_hashes[h]
|
|
|
|
def add(
|
|
self,
|
|
name: str,
|
|
content: str,
|
|
memory_type: str = "project",
|
|
description: str = "",
|
|
) -> Optional[Path]:
|
|
"""Save a new memory entry and update the index."""
|
|
if _is_quality_enabled() and self.is_duplicate(name, description, content):
|
|
logger.debug(
|
|
"Duplicate memory write blocked within %.0fs window: %s",
|
|
DEDUP_WINDOW_SECONDS,
|
|
name,
|
|
)
|
|
return None
|
|
|
|
stripped_name = name.strip()
|
|
if not stripped_name:
|
|
raise ValueError("memory name must not be empty or whitespace-only")
|
|
if memory_type not in MEMORY_TYPES:
|
|
raise ValueError(f"memory_type must be one of: {', '.join(MEMORY_TYPES)}")
|
|
|
|
slug = _SLUG_DISALLOWED_RE.sub("_", stripped_name.lower())[:60]
|
|
if slug.strip("_") == "":
|
|
digest = hashlib.sha256(stripped_name.encode("utf-8")).hexdigest()[:6]
|
|
slug = f"{slug}_{digest}" if slug else digest
|
|
|
|
from src.config.accessor import get_env_config
|
|
if get_env_config().memory.hierarchy_enabled:
|
|
from src.memory.hierarchy import MemoryHierarchy
|
|
hierarchy = MemoryHierarchy(self._dir)
|
|
path = hierarchy.route_entry(memory_type, slug)
|
|
else:
|
|
filename = f"{memory_type}_{slug}.md"
|
|
path = self._dir / filename
|
|
safe_name = stripped_name.replace("\n", " ").replace("\r", " ")
|
|
safe_desc = (description or stripped_name).replace("\n", " ").replace("\r", " ")
|
|
clean_content = _truncate_body(_sanitize_body(content))
|
|
|
|
entry_id = hashlib.sha256(
|
|
f"{stripped_name}{_time.time()}".encode()
|
|
).hexdigest()[:6]
|
|
now_iso = _time.strftime("%Y-%m-%dT%H:%M:%S", _time.gmtime())
|
|
|
|
frontmatter = (
|
|
f"---\nname: {safe_name}\n"
|
|
f"description: {safe_desc}\n"
|
|
f"type: {memory_type}\n"
|
|
f"id: {entry_id}\n"
|
|
f"created_at: {now_iso}\n"
|
|
f"updated_at: {now_iso}\n"
|
|
f"keywords: []\n"
|
|
f"quality_score: 0.5\n"
|
|
f"access_count: 0\n"
|
|
f"last_accessed: {now_iso}\n"
|
|
f"importance: 0.5\n"
|
|
f"related_memories: []\n"
|
|
f"category: {memory_type}\n"
|
|
f"compression_level: raw\n"
|
|
f"---\n\n"
|
|
f"{clean_content}"
|
|
)
|
|
with memory_lock(self._dir) as acquired:
|
|
if not acquired:
|
|
logger.warning(
|
|
"add(%s): lock timeout, best-effort write", stripped_name
|
|
)
|
|
path.write_text(frontmatter, encoding="utf-8")
|
|
self._update_index(stripped_name, path.name, description or stripped_name)
|
|
|
|
if get_env_config().memory.links_enabled:
|
|
try:
|
|
from src.memory.semantic_links import SemanticLinker, _tokenize_for_bm25
|
|
linker = SemanticLinker(self._dir)
|
|
all_entries = self._scan_entries()
|
|
new_entry = next((e for e in all_entries if e.path == path), None)
|
|
if new_entry:
|
|
entry_tokens = _tokenize_for_bm25(
|
|
f"{new_entry.title} {new_entry.description} {new_entry.body}"
|
|
)
|
|
all_entries_data = [
|
|
(e.path.name, _tokenize_for_bm25(
|
|
f"{e.title} {e.description} {e.body}"
|
|
))
|
|
for e in all_entries if e.path != path
|
|
]
|
|
links = linker.discover_links(
|
|
entry_title=new_entry.path.name,
|
|
entry_tokens=entry_tokens,
|
|
all_entries_data=all_entries_data,
|
|
)
|
|
if links:
|
|
linker.save_relations(path, links)
|
|
except Exception:
|
|
logger.debug("semantic link discovery failed", exc_info=True)
|
|
|
|
if get_env_config().memory.fts_index_enabled:
|
|
try:
|
|
from src.memory.search_index import get_shared_index
|
|
index = get_shared_index()
|
|
index.index_entry(
|
|
entry_id=entry_id,
|
|
title=safe_name,
|
|
description=safe_desc,
|
|
keywords="",
|
|
body=clean_content,
|
|
)
|
|
except Exception:
|
|
logger.debug("FTS5 index_entry failed", exc_info=True)
|
|
return path
|
|
|
|
def remove(self, name: str) -> bool:
|
|
"""Remove a memory entry by name. Returns True if found and removed."""
|
|
from src.config.accessor import get_env_config
|
|
|
|
for entry in self._scan_entries():
|
|
if entry.title == name:
|
|
with memory_lock(self._dir) as acquired:
|
|
if not acquired:
|
|
logger.warning("remove(%s): lock timeout", name)
|
|
entry.path.unlink(missing_ok=True)
|
|
self._rebuild_index()
|
|
|
|
if get_env_config().memory.fts_index_enabled:
|
|
try:
|
|
from src.memory.search_index import get_shared_index
|
|
get_shared_index().remove_entry(entry.id)
|
|
except Exception:
|
|
logger.debug("FTS5 remove_entry failed", exc_info=True)
|
|
|
|
if get_env_config().memory.links_enabled:
|
|
try:
|
|
from src.memory.semantic_links import SemanticLinker
|
|
SemanticLinker(self._dir).remove_relations(entry.path)
|
|
except Exception:
|
|
logger.debug("Failed to remove relations for %s", entry.path, exc_info=True)
|
|
|
|
return True
|
|
return False
|
|
|
|
def _update_index(self, title: str, filename: str, description: str) -> None:
|
|
"""Append or update an entry in MEMORY.md."""
|
|
new_line = f"- [{title}]({filename}) — {description}"
|
|
|
|
if self._index_path.exists():
|
|
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
|
updated = False
|
|
for i, line in enumerate(lines):
|
|
if f"[{title}]" in line:
|
|
lines[i] = new_line
|
|
updated = True
|
|
break
|
|
if not updated:
|
|
lines.append(new_line)
|
|
text = "\n".join(lines[:MAX_INDEX_LINES])
|
|
else:
|
|
text = new_line
|
|
|
|
self._index_path.write_text(text, encoding="utf-8")
|
|
|
|
def _rebuild_index(self) -> None:
|
|
"""Rebuild MEMORY.md from all existing entry files."""
|
|
entries = self._scan_entries()
|
|
lines = [f"- [{e.title}]({e.path.name}) — {e.description}" for e in entries]
|
|
self._index_path.write_text(
|
|
"\n".join(lines[:MAX_INDEX_LINES]), encoding="utf-8"
|
|
)
|