ReMe/reme2/memory/schema/memory.py
huangsen dd2de16481 ```
feat(file_watcher): add directory deletion support with descendant indexing

Add support for deleting entire directories and their indexed descendants
in the file watcher. Previously only individual file deletions were
handled properly. Now when a directory is deleted, the system finds all
indexed files beneath that directory path and removes them along with
their metadata and chunks.

The implementation includes:
- New `_descendant_indexed_paths` method to find all indexed files
  under a given directory path
- Updated `_on_deleted` method to process both the target path and
  all its indexed descendants
- Proper handling of symlinks and path resolution differences
- Enhanced logging to show directory deletion with child count

Also adds necessary os import for path operations.

refactor(config): restructure configuration profiles for clarity

Rename curated.yaml to remove outdated configuration file and
rename full.yaml to expert.yaml with updated documentation.
Add new service.yaml configuration profile that provides a
service-aligned MCP surface with three main tools:
- retrieve: graph-aware hybrid retrieval
- remember: single write entry point with log/distill modes
- maintain: vault hygiene sweep

The expert configuration now excludes the ingest tool since
cold-path operations are handled by external agents, and adds
memory_lint tool for structural issue detection.

Updated documentation to clarify the different configuration
profiles and their intended usage patterns.
```
2026-05-11 10:52:25 +08:00

317 lines
10 KiB
Python

"""Memory — the typed shape of a vault frontmatter.
Single class covers every memory in the vault. The four orthogonal axes
(`lifecycle / scope / source / role`) define the *behavioral shape* —
services read off them to decide decay rules, retrieval ranking, merge
eligibility, etc., instead of switching on a `category` string.
Role-conditional fields (`confidence`, `status`, `origin_session_id`)
are validated declaratively via `model_validator(mode='after')`.
Legacy migration: a `model_validator(mode='before')` recognizes the old
`category` field and back-fills the 4 axes via `LEGACY_AXES_FROM_CATEGORY`,
so vault files written before this schema continue to parse without a
migration script.
Lives in `reme2/memory/schema/` (with the services that consume it),
NOT in `reme2/component/` — the engine is domain-agnostic and never
imports this module.
"""
from __future__ import annotations
from datetime import date, datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ---------------------------------------------------------------------------
# The four behavioral axes
# ---------------------------------------------------------------------------
class Lifecycle(StrEnum):
"""Mutability + decay disposition of a memory.
streaming — write-once events that decay/archive after a freshness window.
evolving — long-lived memories that get continuously edited; never decay.
frozen — immutable references / artifacts; never edited, never decay.
"""
STREAMING = "streaming"
EVOLVING = "evolving"
FROZEN = "frozen"
class Scope(StrEnum):
"""Referential range of a memory.
instance — refers to a specific moment / object / occurrence.
class — abstracts over many instances; a concept / role / pattern.
"""
INSTANCE = "instance"
CLASS = "class"
class Source(StrEnum):
"""Provenance of the memory.
auto — captured by the system from a session / tool output.
curated — written or shaped by a human (or LLM curator) on purpose.
derived — computed from other memories (Maintainer products, summaries).
"""
AUTO = "auto"
CURATED = "curated"
DERIVED = "derived"
class Role(StrEnum):
"""Cognitive role the memory plays.
Drives schema-aware ranking + role-specific field validation.
Adding a new role is a one-line change here plus (optionally) a preset.
"""
OBSERVATION = "observation" # what happened (events live here)
CLAIM = "claim" # an assertion needing confidence (thesis, model)
QUESTION = "question" # an open inquiry needing an answer
PROFILE = "profile" # entity description (person, org, system)
CONCEPT = "concept" # abstract idea / definition (company, sector, concept)
METHOD = "method" # procedure / how-to
REFERENCE = "reference" # pointer to external thing (tool, paper, code)
FUNDAMENTALS = "fundamentals" # foundational data / baseline facts
class Status(StrEnum):
"""Lifecycle state — meaningful only for `lifecycle == streaming`.
Topic-style memories (evolving / frozen) ignore this field.
"""
ACTIVE = "active"
DISTILLED = "distilled"
ARCHIVED = "archived"
class Confidence(StrEnum):
"""Required for `role == claim`. Wire format stays emoji.
PENDING — claim under investigation; outcome not yet decided.
VERIFIED — claim supported by evidence and currently held.
REJECTED — claim was investigated and disconfirmed; kept for history.
"""
PENDING = "⏳"
VERIFIED = "✅"
REJECTED = "❌"
# ---------------------------------------------------------------------------
# Legacy `category` → 4-axis mapping (auto-migration)
# ---------------------------------------------------------------------------
LEGACY_AXES_FROM_CATEGORY: dict[str, dict] = {
# Events: streaming-instance-auto-observation
"event": {
"lifecycle": Lifecycle.STREAMING,
"scope": Scope.INSTANCE,
"source": Source.AUTO,
"role": Role.OBSERVATION,
},
# Topic categories — all evolving / class / curated, role varies
"company": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.CONCEPT,
},
"sector": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.CONCEPT,
},
"concept": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.CONCEPT,
},
"method": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.METHOD,
},
"tool": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.REFERENCE,
},
"profile": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.PROFILE,
},
"thesis": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.CLAIM,
},
"model": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.CLAIM,
},
"questions": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.QUESTION,
},
"fundamentals": {
"lifecycle": Lifecycle.EVOLVING,
"scope": Scope.CLASS,
"source": Source.CURATED,
"role": Role.FUNDAMENTALS,
},
# Materials: frozen-instance-auto-reference (siblings of an event index)
"material": {
"lifecycle": Lifecycle.FROZEN,
"scope": Scope.INSTANCE,
"source": Source.AUTO,
"role": Role.REFERENCE,
},
}
# ---------------------------------------------------------------------------
# Memory — the single typed shape
# ---------------------------------------------------------------------------
class Memory(BaseModel):
"""The typed view of a vault frontmatter.
`extra="allow"` keeps domain-specific fields (market, ticker, etc.) in
the parsed object without polluting this base schema. `populate_by_name`
lets `originSessionId` (legacy camelCase) populate `origin_session_id`.
"""
model_config = ConfigDict(extra="allow", populate_by_name=True)
# -- Identity / common metadata ----------------------------------------
title: str = Field(default="")
description: str = Field(default="")
tags: list[str] = Field(default_factory=list)
created: date | None = None
updated: date | None = None
# -- The four behavioral axes ------------------------------------------
lifecycle: Lifecycle
scope: Scope
source: Source
role: Role
# -- Cross-cutting graph fields ----------------------------------------
topics: list[str] = Field(default_factory=list, description="Outbound wikilinks to class memories.")
parent: str | None = Field(default=None, description="Wikilink to owning memory (e.g. material → event).")
# -- Role / lifecycle / source-conditional fields ----------------------
confidence: Confidence | None = Field(
default=None,
description="Required when role == claim. Use ⏳ / ✅ / ❌ as wire form.",
)
status: Status | None = Field(
default=None,
description="Lifecycle state. Meaningful only when lifecycle == streaming.",
)
origin_session_id: str | None = Field(
default=None,
alias="originSessionId",
description="Capture session id. Set when source == auto.",
)
# -- Migration: pre-validate hook --------------------------------------
@model_validator(mode="before")
@classmethod
def _migrate_legacy_category(cls, data):
"""Back-fill 4 axes from old `category` field if present.
Idempotent: if axes are already populated, do nothing. The legacy
`category` field is preserved (via `extra="allow"`) so downstream
code that still reads it keeps working until we delete it.
"""
if not isinstance(data, dict):
return data
if "role" in data and "lifecycle" in data:
return data
cat = data.get("category")
if cat in LEGACY_AXES_FROM_CATEGORY:
data = {**LEGACY_AXES_FROM_CATEGORY[cat], **data}
return data
# -- Field coercion / cleanup ------------------------------------------
@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
@field_validator("tags", "topics", mode="before")
@classmethod
def _strip_dedup(cls, v):
"""Strip whitespace + dedup string lists. Preserves order."""
if not isinstance(v, list):
return v
seen: dict[str, None] = {}
for item in v:
if isinstance(item, str):
stripped = item.strip()
if stripped:
seen.setdefault(stripped, None)
return list(seen.keys())
# -- Role-conditional checks -------------------------------------------
@model_validator(mode="after")
def _enforce_role_conditionals(self):
if self.role is Role.CLAIM and self.confidence is None:
raise ValueError(
"role='claim' requires explicit confidence "
"(one of ⏳ / ✅ / ❌)"
)
return self
# -- Convenience properties --------------------------------------------
@property
def is_active(self) -> bool:
"""True for streaming memories that are still mutable."""
return self.lifecycle is Lifecycle.STREAMING and self.status is Status.ACTIVE
@property
def is_terminal(self) -> bool:
"""True for memories no longer accepting writes (distilled / archived / frozen)."""
if self.lifecycle is Lifecycle.FROZEN:
return True
return self.status in (Status.DISTILLED, Status.ARCHIVED)