chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
49
src/everos/infra/persistence/markdown/readers/__init__.py
Normal file
49
src/everos/infra/persistence/markdown/readers/__init__.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Business markdown readers — symmetric with the writers.
|
||||
|
||||
Daily-log markdown is parsed via :class:`MarkdownReader` from ``core``
|
||||
(the base reader returns frontmatter dict + body + entry markers, all
|
||||
schema-agnostic). Reader classes here add the **business-aware
|
||||
locator** layer:
|
||||
|
||||
* :class:`BaseDailyReader` + subclasses — bind a daily-log schema,
|
||||
resolve ``(scope_id, date)`` to a file, locate entries by id,
|
||||
and optionally upgrade to :class:`StructuredEntry`. Symmetric
|
||||
with :class:`BaseDailyWriter`.
|
||||
* :class:`AgentSkillReader` — reads ``SKILL.md`` and parses the
|
||||
frontmatter into the caller-supplied ``AgentSkillFrontmatter``
|
||||
subclass; also reads individual reference / script files.
|
||||
* :class:`ProfileReader` — reads a fixed-name profile file
|
||||
(``user.md`` / ``agent.md`` / ``soul.md`` / …) and parses its
|
||||
frontmatter into the caller-supplied schema.
|
||||
|
||||
By design, no batch / list APIs live here: bulk enumeration for
|
||||
prompt-budget or cross-record queries goes through sqlite/lancedb
|
||||
(see the cascade daemon's index sync), not a markdown directory walk.
|
||||
|
||||
External usage::
|
||||
|
||||
from everos.infra.persistence.markdown.readers import (
|
||||
BaseDailyReader,
|
||||
EpisodeReader,
|
||||
AgentSkillReader,
|
||||
ProfileReader,
|
||||
)
|
||||
"""
|
||||
|
||||
from .agent_case_reader import AgentCaseReader as AgentCaseReader
|
||||
from .agent_skill_reader import AgentSkillReader as AgentSkillReader
|
||||
from .atomic_fact_reader import AtomicFactReader as AtomicFactReader
|
||||
from .base import BaseDailyReader as BaseDailyReader
|
||||
from .episode_reader import EpisodeReader as EpisodeReader
|
||||
from .foresight_reader import ForesightReader as ForesightReader
|
||||
from .profile_reader import ProfileReader as ProfileReader
|
||||
|
||||
__all__ = [
|
||||
"AgentCaseReader",
|
||||
"AgentSkillReader",
|
||||
"AtomicFactReader",
|
||||
"BaseDailyReader",
|
||||
"EpisodeReader",
|
||||
"ForesightReader",
|
||||
"ProfileReader",
|
||||
]
|
||||
@ -0,0 +1,31 @@
|
||||
"""AgentCase daily-log reader — symmetric with :class:`AgentCaseWriter`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
from ..mds import AgentCaseDailyFrontmatter
|
||||
from .base import BaseDailyReader
|
||||
|
||||
|
||||
class AgentCaseReader(BaseDailyReader):
|
||||
"""Read agent-case daily-log files."""
|
||||
|
||||
schema = AgentCaseDailyFrontmatter
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
super().__init__(root)
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
agent_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Resolve the agent-case daily-log path under the <app>/<project> prefix."""
|
||||
return super().path_for(agent_id, date, app_id=app_id, project_id=project_id)
|
||||
@ -0,0 +1,161 @@
|
||||
"""AgentSkillReader — typed read for the AgentSkill directory layout.
|
||||
|
||||
Pairs with :class:`AgentSkillWriter`:
|
||||
|
||||
- :meth:`read_main` reads ``SKILL.md`` and returns the caller's
|
||||
:class:`AgentSkillFrontmatter` subclass instance + the Tier-2 body, so
|
||||
the caller never deals with raw dicts.
|
||||
- :meth:`read_reference` / :meth:`read_script` are plain text reads;
|
||||
no frontmatter, no schema.
|
||||
|
||||
All three return ``None`` when the target is missing — readers do not
|
||||
raise on absence, since "skill not yet created" is a normal state for
|
||||
the upsert-style workflow. Callers that need to distinguish "missing"
|
||||
from "empty body" check for ``None`` explicitly.
|
||||
|
||||
Path resolution mirrors :class:`AgentSkillWriter` and reads the same
|
||||
ClassVars off :class:`AgentSkillFrontmatter`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
||||
import anyio
|
||||
|
||||
from everos.core.persistence import MarkdownReader, MemoryRoot
|
||||
|
||||
from ..mds import AgentSkillFrontmatter
|
||||
|
||||
T = TypeVar("T", bound=AgentSkillFrontmatter)
|
||||
|
||||
|
||||
class AgentSkillReader:
|
||||
"""Single-skill reader for the directory + progressive-disclosure layout."""
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
self._root = root
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
async def read_main(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
*,
|
||||
schema: type[T],
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> tuple[T, str] | None:
|
||||
"""Read ``SKILL.md`` and parse its frontmatter into ``schema``.
|
||||
|
||||
Args:
|
||||
schema: Concrete :class:`AgentSkillFrontmatter` subclass. The
|
||||
frontmatter dict is validated against this schema via
|
||||
:meth:`pydantic.BaseModel.model_validate`; extra fields
|
||||
ride along (chassis sets ``extra="allow"``).
|
||||
|
||||
Returns:
|
||||
``(frontmatter, body)`` on success, ``None`` if the file
|
||||
does not exist. ``body`` is the raw text after the closing
|
||||
``---``; the trailing newline added by :class:`AgentSkillWriter`
|
||||
is stripped to give the *logical* body back.
|
||||
"""
|
||||
path = self._main_path(agent_id, skill_name, app_id, project_id)
|
||||
if not await anyio.Path(path).is_file():
|
||||
return None
|
||||
parsed = await MarkdownReader.read(path)
|
||||
frontmatter = schema.model_validate(parsed.frontmatter)
|
||||
body = parsed.body.rstrip("\n")
|
||||
return frontmatter, body
|
||||
|
||||
async def read_reference(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
reference_name: str,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> str | None:
|
||||
"""Read ``references/<reference_name>.md`` verbatim, ``None`` if absent."""
|
||||
path = self._reference_path(
|
||||
agent_id, skill_name, reference_name, app_id, project_id
|
||||
)
|
||||
apath = anyio.Path(path)
|
||||
if not await apath.is_file():
|
||||
return None
|
||||
text = await apath.read_text(encoding="utf-8")
|
||||
return text.rstrip("\n")
|
||||
|
||||
async def read_script(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
script_filename: str,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> str | None:
|
||||
"""Read ``scripts/<script_filename>`` verbatim, ``None`` if absent.
|
||||
|
||||
Reading ≠ executing — this only returns the source text.
|
||||
Sandboxing / exec-policy decisions belong to the caller.
|
||||
"""
|
||||
path = self._script_path(
|
||||
agent_id, skill_name, script_filename, app_id, project_id
|
||||
)
|
||||
apath = anyio.Path(path)
|
||||
if not await apath.is_file():
|
||||
return None
|
||||
text = await apath.read_text(encoding="utf-8")
|
||||
return text.rstrip("\n")
|
||||
|
||||
# ── Internals — same shape as AgentSkillWriter ────────────────────────────
|
||||
|
||||
def _skill_dir(
|
||||
self, agent_id: str, skill_name: str, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
return (
|
||||
self._root.agents_dir(app_id, project_id)
|
||||
/ agent_id
|
||||
/ AgentSkillFrontmatter.SKILLS_CONTAINER_NAME
|
||||
/ f"{AgentSkillFrontmatter.SKILL_DIR_PREFIX}{skill_name}"
|
||||
)
|
||||
|
||||
def _main_path(
|
||||
self, agent_id: str, skill_name: str, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_MAIN_FILENAME
|
||||
)
|
||||
|
||||
def _reference_path(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
reference_name: str,
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> Path:
|
||||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_REFERENCES_DIR_NAME
|
||||
/ f"{reference_name}.md"
|
||||
)
|
||||
|
||||
def _script_path(
|
||||
self,
|
||||
agent_id: str,
|
||||
skill_name: str,
|
||||
script_filename: str,
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> Path:
|
||||
return (
|
||||
self._skill_dir(agent_id, skill_name, app_id, project_id)
|
||||
/ AgentSkillFrontmatter.SKILL_SCRIPTS_DIR_NAME
|
||||
/ script_filename
|
||||
)
|
||||
@ -0,0 +1,31 @@
|
||||
"""AtomicFact daily-log reader — symmetric with :class:`AtomicFactWriter`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
from ..mds import AtomicFactDailyFrontmatter
|
||||
from .base import BaseDailyReader
|
||||
|
||||
|
||||
class AtomicFactReader(BaseDailyReader):
|
||||
"""Read atomic-fact daily-log files."""
|
||||
|
||||
schema = AtomicFactDailyFrontmatter
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
super().__init__(root)
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
owner_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Resolve the atomic-fact daily-log path under the <app>/<project> prefix."""
|
||||
return super().path_for(owner_id, date, app_id=app_id, project_id=project_id)
|
||||
177
src/everos/infra/persistence/markdown/readers/base.py
Normal file
177
src/everos/infra/persistence/markdown/readers/base.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Base business reader for daily-log markdown files.
|
||||
|
||||
Symmetric to :class:`BaseDailyWriter`: reads the daily-log file for
|
||||
a given ``(scope_id, date)``, locates entries by id within it, and
|
||||
optionally upgrades them to :class:`StructuredEntry` so service-layer
|
||||
callers don't have to re-do that plumbing each time.
|
||||
|
||||
Subclass usage::
|
||||
|
||||
class _MemcellReader(BaseDailyReader):
|
||||
schema = UserMemcellDailyFrontmatter
|
||||
|
||||
reader = _MemcellReader(root)
|
||||
parsed = reader.read_for("u_jason") # today's file
|
||||
entry = reader.find_entry("u_jason", "umc_20260422_0001")
|
||||
structured = reader.find_structured("u_jason", entry.id)
|
||||
|
||||
The reader does **not** typed-parse the file's frontmatter dict — the
|
||||
schema is used only for path resolution (matching what the appender
|
||||
writes). Frontmatter validation belongs to higher-level callers that
|
||||
know the business rules.
|
||||
|
||||
Path resolution is identical to :class:`BaseDailyWriter` (same
|
||||
``SCOPE_DIR`` / ``DIR_NAME`` / ``FILE_PREFIX`` ClassVars), so a
|
||||
reader and writer bound to the same schema agree on every path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import anyio
|
||||
|
||||
from everos.component.utils.datetime import today_with_timezone
|
||||
from everos.core.persistence import (
|
||||
BaseFrontmatter,
|
||||
Entry,
|
||||
EntryId,
|
||||
MarkdownReader,
|
||||
MemoryRoot,
|
||||
ParsedMarkdown,
|
||||
StructuredEntry,
|
||||
find_entry,
|
||||
)
|
||||
|
||||
|
||||
class BaseDailyReader:
|
||||
"""Single-record reader for daily-log markdown files.
|
||||
|
||||
Subclasses bind a :class:`BaseFrontmatter` subclass via the
|
||||
``schema`` ClassVar. The schema must declare ``SCOPE_DIR``,
|
||||
``DIR_NAME``, and ``FILE_PREFIX`` (same set the appender uses); no
|
||||
``ENTRY_ID_PREFIX`` requirement here because the reader takes the
|
||||
entry id from the caller, not the schema.
|
||||
"""
|
||||
|
||||
schema: ClassVar[type[BaseFrontmatter]] # subclass must declare
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
schema = getattr(type(self), "schema", None)
|
||||
if schema is None:
|
||||
raise TypeError(
|
||||
f"{type(self).__name__} must declare a class-level ``schema`` attribute"
|
||||
)
|
||||
for attr in ("SCOPE_DIR", "DIR_NAME", "FILE_PREFIX"):
|
||||
if not getattr(schema, attr, None):
|
||||
raise TypeError(f"{schema.__name__} missing ClassVar {attr!r}")
|
||||
self._root = root
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
async def read_for(
|
||||
self,
|
||||
scope_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> ParsedMarkdown | None:
|
||||
"""Read the daily-log file for ``(scope_id, date)``.
|
||||
|
||||
Args:
|
||||
scope_id: ``user_id`` or ``agent_id``.
|
||||
date: Date bucket — defaults to today in the configured TZ.
|
||||
app_id: App scope segment (defaults to the ``"default"`` space).
|
||||
project_id: Project scope segment (defaults to ``"default"``).
|
||||
|
||||
Returns:
|
||||
:class:`ParsedMarkdown` (frontmatter dict + body + entries),
|
||||
or ``None`` when the file does not exist on disk. ``None``
|
||||
avoids forcing every caller to wrap reads in try/except —
|
||||
"no file yet" is a normal early state.
|
||||
"""
|
||||
path = self._resolve_path(
|
||||
scope_id, date or today_with_timezone(), app_id, project_id
|
||||
)
|
||||
if not await anyio.Path(path).is_file():
|
||||
return None
|
||||
return await MarkdownReader.read(path)
|
||||
|
||||
async def find_entry(
|
||||
self,
|
||||
scope_id: str,
|
||||
entry_id: str | EntryId,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Entry | None:
|
||||
"""Locate the entry with ``entry_id`` inside its daily-log file.
|
||||
|
||||
The date bucket is taken from the entry id (an :class:`EntryId`
|
||||
encodes its own date), so the caller doesn't pass a date.
|
||||
Returns ``None`` if either the file or the entry is missing.
|
||||
"""
|
||||
eid = entry_id if isinstance(entry_id, EntryId) else EntryId.parse(entry_id)
|
||||
eid_str = eid.format()
|
||||
parsed = await self.read_for(
|
||||
scope_id, eid.date, app_id=app_id, project_id=project_id
|
||||
)
|
||||
if parsed is None:
|
||||
return None
|
||||
return find_entry(parsed.body, eid_str)
|
||||
|
||||
async def find_structured(
|
||||
self,
|
||||
scope_id: str,
|
||||
entry_id: str | EntryId,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> StructuredEntry | None:
|
||||
"""Locate the entry and parse its body as audit-form data.
|
||||
|
||||
Sugar over :meth:`find_entry` + :meth:`Entry.as_structured`.
|
||||
Returns ``None`` if the entry is missing.
|
||||
"""
|
||||
entry = await self.find_entry(
|
||||
scope_id, entry_id, app_id=app_id, project_id=project_id
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
return entry.as_structured()
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
scope_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Return the daily-log path for ``scope_id`` on ``date`` (today default).
|
||||
|
||||
Public counterpart of :meth:`_resolve_path` — symmetric with
|
||||
:meth:`BaseDailyWriter.path_for`. Does not check existence.
|
||||
"""
|
||||
return self._resolve_path(
|
||||
scope_id, date or today_with_timezone(), app_id, project_id
|
||||
)
|
||||
|
||||
# ── Internals ─────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_path(
|
||||
self, scope_id: str, date: _dt.date, app_id: str, project_id: str
|
||||
) -> Path:
|
||||
"""Build the daily-log path for ``scope_id`` on ``date``."""
|
||||
# SCOPE_DIR ("users" / "agents") names the matching MemoryRoot method,
|
||||
# which prepends the <app>/<project> business prefix.
|
||||
scope_dir = getattr(self._root, f"{self.schema.SCOPE_DIR}_dir")
|
||||
return (
|
||||
scope_dir(app_id, project_id)
|
||||
/ scope_id
|
||||
/ self.schema.DIR_NAME
|
||||
/ f"{self.schema.FILE_PREFIX}-{date.isoformat()}.md"
|
||||
)
|
||||
@ -0,0 +1,41 @@
|
||||
"""Episode daily-log reader — symmetric with :class:`EpisodeWriter`.
|
||||
|
||||
md is the source of truth for Episode memories; this reader gives
|
||||
cascade / search / verification scripts a typed locator instead of
|
||||
raw :class:`MarkdownReader` calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
from ..mds import EpisodeDailyFrontmatter
|
||||
from .base import BaseDailyReader
|
||||
|
||||
|
||||
class EpisodeReader(BaseDailyReader):
|
||||
"""Read episode daily-log files."""
|
||||
|
||||
schema = EpisodeDailyFrontmatter
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
super().__init__(root)
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
owner_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Resolve the daily-log path for ``owner_id`` on ``date`` (today by default).
|
||||
|
||||
Mirrors :meth:`EpisodeWriter`'s path-resolution shape so callers
|
||||
can locate the file written for a given owner / day (under the
|
||||
``<app>/<project>`` prefix) without instantiating the writer.
|
||||
"""
|
||||
return super().path_for(owner_id, date, app_id=app_id, project_id=project_id)
|
||||
@ -0,0 +1,31 @@
|
||||
"""Foresight daily-log reader — symmetric with :class:`ForesightWriter`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
from pathlib import Path
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
from ..mds import ForesightDailyFrontmatter
|
||||
from .base import BaseDailyReader
|
||||
|
||||
|
||||
class ForesightReader(BaseDailyReader):
|
||||
"""Read foresight daily-log files."""
|
||||
|
||||
schema = ForesightDailyFrontmatter
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
super().__init__(root)
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
owner_id: str,
|
||||
date: _dt.date | None = None,
|
||||
*,
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Resolve the foresight daily-log path under the <app>/<project> prefix."""
|
||||
return super().path_for(owner_id, date, app_id=app_id, project_id=project_id)
|
||||
@ -0,0 +1,96 @@
|
||||
"""ProfileReader — typed read for the single-file profile layout.
|
||||
|
||||
Pairs with :class:`ProfileWriter`. The schema (concrete profile
|
||||
frontmatter class) is supplied per call; the reader pulls
|
||||
``SCOPE_DIR`` + ``PROFILE_FILENAME`` ClassVars off it to build the
|
||||
path, then ``MarkdownReader.read`` + ``schema.model_validate`` give
|
||||
back a typed frontmatter instance plus the body string.
|
||||
|
||||
Returns ``None`` when the profile file does not exist — "not yet
|
||||
written" is a normal early state for the upsert-style workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
||||
import anyio
|
||||
|
||||
from everos.core.persistence import BaseFrontmatter, MarkdownReader, MemoryRoot
|
||||
|
||||
T = TypeVar("T", bound=BaseFrontmatter)
|
||||
|
||||
|
||||
class ProfileReader:
|
||||
"""Typed read for fixed-name profile markdown files."""
|
||||
|
||||
def __init__(self, root: MemoryRoot) -> None:
|
||||
self._root = root
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
async def read(
|
||||
self,
|
||||
scope_id: str,
|
||||
*,
|
||||
schema: type[T],
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> tuple[T, str] | None:
|
||||
"""Read the profile file and parse its frontmatter into ``schema``.
|
||||
|
||||
Args:
|
||||
scope_id: ``user_id`` or ``agent_id`` (must match the
|
||||
schema's scope mixin).
|
||||
schema: Concrete profile frontmatter class — must declare
|
||||
``SCOPE_DIR`` (via scope mixin) and ``PROFILE_FILENAME``.
|
||||
app_id: App scope segment (defaults to the ``"default"`` space).
|
||||
project_id: Project scope segment (defaults to ``"default"``).
|
||||
|
||||
Returns:
|
||||
``(frontmatter, body)`` on success; ``None`` if the file is
|
||||
missing. ``body`` is the raw text after the closing ``---``
|
||||
with the writer-added trailing newline stripped.
|
||||
"""
|
||||
path = self._resolve_path(scope_id, schema, app_id, project_id)
|
||||
if not await anyio.Path(path).is_file():
|
||||
return None
|
||||
parsed = await MarkdownReader.read(path)
|
||||
frontmatter = schema.model_validate(parsed.frontmatter)
|
||||
body = parsed.body.rstrip("\n")
|
||||
return frontmatter, body
|
||||
|
||||
def path_for(
|
||||
self,
|
||||
scope_id: str,
|
||||
*,
|
||||
schema: type[BaseFrontmatter],
|
||||
app_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> Path:
|
||||
"""Return the profile path (no IO check)."""
|
||||
return self._resolve_path(scope_id, schema, app_id, project_id)
|
||||
|
||||
# ── Internals — same shape as ProfileWriter ───────────────────────────
|
||||
|
||||
def _resolve_path(
|
||||
self,
|
||||
scope_id: str,
|
||||
schema: type[BaseFrontmatter],
|
||||
app_id: str,
|
||||
project_id: str,
|
||||
) -> Path:
|
||||
scope_dir = getattr(schema, "SCOPE_DIR", "")
|
||||
filename = getattr(schema, "PROFILE_FILENAME", None)
|
||||
if not scope_dir:
|
||||
raise TypeError(
|
||||
f"{schema.__name__} missing ``SCOPE_DIR`` ClassVar — "
|
||||
"must inherit a scope mixin (UserScopedFrontmatter / "
|
||||
"AgentScopedFrontmatter)."
|
||||
)
|
||||
if not filename:
|
||||
raise TypeError(f"{schema.__name__} missing ``PROFILE_FILENAME`` ClassVar.")
|
||||
# SCOPE_DIR names the matching MemoryRoot method (<app>/<project> prefix).
|
||||
scope_root = getattr(self._root, f"{scope_dir}_dir")(app_id, project_id)
|
||||
return scope_root / scope_id / filename
|
||||
Reference in New Issue
Block a user