470cf75722
Merged upstream/main (418abca, release v0.3.0) into our fork on a
dedicated branch. 21 conflicting files resolved; main worktree untouched.
Resolution policy and key decisions:
- Keep Ai4Sci runtime endpoints, durable dispatch, workspace scopes and
the HITL/DynamicReview approval chain (approval path is product-critical).
- Adopt upstream model registry (llm/registry.py): our 136 model entries
are a strict subset of upstream's 180, so dropping our inline table
loses nothing and gains 44 new models.
- Adopt upstream native EvoChatDeepSeek; drop our obsolete
_patch_deepseek_reasoning_passback monkey patch.
- Keep our six patches.py additions, ported onto upstream's new
_OpenAICompatContent class: stable tool-call ids, tool-history
sanitization, drop_reasoning_metadata, empty-SSE keepalive,
extracted-document-text patch, _has_assistant_tool_protocol.
- Keep our skill-budget middleware path (skills=None) instead of passing
skills through, to avoid double loading.
- Keep sanitized error labels (_safe_error_label) while adopting
upstream's injected MiddlewareEventSink for fallback narration.
- Keep port 3076 and the LANGGRAPH_SERVER_URL override; adopt upstream's
host/probe-host handling and CONFIG_DRIFT_SINCE_LAUNCH.
- Adopt upstream dependency stack: deepagents 0.7.6, langchain-quickjs
0.3.7, langgraph-api 0.14; keep our extra deps (rfc8785, pillow,
firecrawl-anydoc, nest-asyncio).
- Align call sites with upstream APIs: create_tool_selector_middleware
now takes events= instead of track_stream_selection=.
779 lines
24 KiB
Python
779 lines
24 KiB
Python
"""File-backed observation memory.
|
|
|
|
Observations are small markdown files under `/memories/observations/`. Each
|
|
file has stable frontmatter for future indexing plus a short body that agents
|
|
can grep and read with ordinary file tools today.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
from collections import OrderedDict
|
|
from dataclasses import replace
|
|
from datetime import UTC, date, datetime
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from filelock import FileLock
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
|
|
from ..search import (
|
|
search_documents,
|
|
)
|
|
from ..types import (
|
|
MemoryScope,
|
|
MemorySourceType,
|
|
MemoryType,
|
|
ObservationReadResult,
|
|
ObservationRecordResult,
|
|
ObservationRelation,
|
|
ObservationSearchDocument,
|
|
ObservationSearchHit,
|
|
ObservationSearchMode,
|
|
RelatedObservationResult,
|
|
)
|
|
|
|
OBSERVATION_DIR = "/observations"
|
|
|
|
|
|
ObservationFrontmatterValue = str | dict[str, str] | list[dict[str, str]]
|
|
ObservationFrontmatterPayload = dict[str, ObservationFrontmatterValue]
|
|
|
|
|
|
class RelatedObservationEntry(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
id: str = Field(min_length=1, strict=True)
|
|
relation: ObservationRelation
|
|
reason: str = Field(min_length=1, strict=True)
|
|
linked_at: str = Field(min_length=1, strict=True)
|
|
|
|
@field_validator("id", "reason", "linked_at")
|
|
@classmethod
|
|
def _non_blank(cls, value: str) -> str:
|
|
if not value.strip():
|
|
raise ValueError("must not be blank")
|
|
return value
|
|
|
|
def to_frontmatter_dict(self) -> dict[str, str]:
|
|
return {
|
|
"id": self.id,
|
|
"relation": self.relation.value,
|
|
"reason": self.reason,
|
|
"linked_at": self.linked_at,
|
|
}
|
|
|
|
|
|
class ObservationSourceFrontmatter(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
type: MemorySourceType
|
|
agent: str = Field(min_length=1, strict=True)
|
|
session_id: str | None = Field(default=None, min_length=1, strict=True)
|
|
|
|
@field_validator("agent", "session_id")
|
|
@classmethod
|
|
def _non_blank(cls, value: str | None) -> str | None:
|
|
if value is not None and not value.strip():
|
|
raise ValueError("must not be blank")
|
|
return value
|
|
|
|
def to_frontmatter_dict(self) -> dict[str, str]:
|
|
payload = {
|
|
"type": self.type.value,
|
|
"agent": self.agent,
|
|
}
|
|
if self.session_id is not None:
|
|
payload["session_id"] = self.session_id
|
|
return payload
|
|
|
|
|
|
class ObservationFrontmatter(BaseModel):
|
|
model_config = ConfigDict(extra="ignore", validate_assignment=True)
|
|
|
|
id: str = Field(min_length=1, strict=True)
|
|
created_at: str | None = Field(default=None, min_length=1, strict=True)
|
|
summary: str = Field(min_length=1, strict=True)
|
|
memory_type: MemoryType
|
|
scope: MemoryScope
|
|
project_id: str | None = Field(default=None, min_length=1, strict=True)
|
|
source: ObservationSourceFrontmatter | None = None
|
|
related_observations: list[RelatedObservationEntry] = Field(default_factory=list)
|
|
|
|
@field_validator("id", "summary", "created_at", "project_id")
|
|
@classmethod
|
|
def _non_blank(cls, value: str | None) -> str | None:
|
|
if value is not None and not value.strip():
|
|
raise ValueError("must not be blank")
|
|
return value
|
|
|
|
@field_validator("created_at", mode="before")
|
|
@classmethod
|
|
def _coerce_yaml_timestamp(cls, value: object) -> object:
|
|
if isinstance(value, datetime):
|
|
if value.tzinfo is not None:
|
|
value = value.astimezone(UTC)
|
|
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
return value.strftime("%Y-%m-%dT%H:%M:%S")
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
return value
|
|
|
|
def to_frontmatter_dict(self) -> ObservationFrontmatterPayload:
|
|
payload: ObservationFrontmatterPayload = {
|
|
"id": self.id,
|
|
}
|
|
if self.created_at is not None:
|
|
payload["created_at"] = self.created_at
|
|
payload["summary"] = self.summary
|
|
payload["memory_type"] = self.memory_type.value
|
|
payload["scope"] = self.scope.value
|
|
if self.project_id is not None:
|
|
payload["project_id"] = self.project_id
|
|
if self.source is not None:
|
|
payload["source"] = self.source.to_frontmatter_dict()
|
|
if self.related_observations:
|
|
payload["related_observations"] = [
|
|
entry.to_frontmatter_dict() for entry in self.related_observations
|
|
]
|
|
return payload
|
|
|
|
|
|
def _normalize(text: str) -> str:
|
|
"""Collapse whitespace before deriving the dedupe id."""
|
|
return " ".join(text.strip().split())
|
|
|
|
|
|
def _observation_id(
|
|
*,
|
|
memory_type: MemoryType,
|
|
scope: MemoryScope,
|
|
observation: str,
|
|
why_it_matters: str,
|
|
) -> str:
|
|
"""Return a deterministic id for semantically identical observations."""
|
|
key = "\n".join(
|
|
[
|
|
memory_type.value,
|
|
scope.value,
|
|
_normalize(observation).casefold(),
|
|
_normalize(why_it_matters).casefold(),
|
|
]
|
|
)
|
|
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
return f"O-{digest}"
|
|
|
|
|
|
def _agent_path(memory_path: str) -> str:
|
|
"""Translate a memory-relative path to the virtual path agents see."""
|
|
return f"/memories{memory_path}"
|
|
|
|
|
|
def _memory_path(
|
|
*,
|
|
observation_id: str,
|
|
scope: MemoryScope,
|
|
project_id: str,
|
|
) -> str:
|
|
"""Return the memory-relative path for an observation id."""
|
|
if scope == MemoryScope.PROJECT:
|
|
return f"{OBSERVATION_DIR}/projects/{project_id}/{observation_id}.md"
|
|
return f"{OBSERVATION_DIR}/global/{observation_id}.md"
|
|
|
|
|
|
def _json_string(value: str) -> str:
|
|
"""Render a string as a YAML-safe JSON scalar."""
|
|
return json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
def _read_observation_document_with_text(
|
|
path: str | Path,
|
|
) -> tuple[ObservationFrontmatter, str, str] | None:
|
|
"""Read an observation markdown document, body, and original text."""
|
|
document_path = Path(path).expanduser()
|
|
try:
|
|
text = document_path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
return None
|
|
if not text.startswith("---\n"):
|
|
return None
|
|
try:
|
|
frontmatter, body = text.removeprefix("---\n").split("\n---\n", 1)
|
|
metadata = ObservationFrontmatter.model_validate(yaml.safe_load(frontmatter))
|
|
except (ValueError, ValidationError, yaml.YAMLError):
|
|
return None
|
|
return metadata, body, text
|
|
|
|
|
|
def read_observation_document(
|
|
path: str | Path,
|
|
) -> tuple[ObservationFrontmatter, str] | None:
|
|
"""Read an observation markdown document and parse its frontmatter."""
|
|
document = _read_observation_document_with_text(path)
|
|
if document is None:
|
|
return None
|
|
metadata, body, _text = document
|
|
return metadata, body
|
|
|
|
|
|
def write_observation_document(
|
|
path: str | Path,
|
|
*,
|
|
metadata: ObservationFrontmatter,
|
|
body: str,
|
|
) -> None:
|
|
"""Write an observation markdown document with frontmatter."""
|
|
frontmatter = yaml.safe_dump(
|
|
metadata.to_frontmatter_dict(),
|
|
allow_unicode=True,
|
|
sort_keys=False,
|
|
)
|
|
_atomic_write_text(Path(path), f"---\n{frontmatter}---\n{body}")
|
|
|
|
|
|
def _atomic_write_text(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(
|
|
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write(content)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
try:
|
|
os.unlink(temporary)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def read_observation_id_from_path(path: str | Path) -> str | None:
|
|
"""Read an observation id from a concrete markdown file path."""
|
|
document = read_observation_document(path)
|
|
if document is None:
|
|
return None
|
|
metadata, _body = document
|
|
return metadata.id.strip()
|
|
|
|
|
|
def related_observation_entries(
|
|
metadata: ObservationFrontmatter,
|
|
) -> list[RelatedObservationEntry]:
|
|
"""Return related-observation frontmatter entries."""
|
|
return list(metadata.related_observations)
|
|
|
|
|
|
def _observation_files(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
scope: MemoryScope | None,
|
|
) -> list[Path]:
|
|
"""Return candidate observation files for the current project context."""
|
|
root = Path(memory_dir).expanduser()
|
|
paths: list[Path] = []
|
|
if scope in {None, MemoryScope.GLOBAL}:
|
|
paths.extend(_global_files(root))
|
|
if scope in {None, MemoryScope.PROJECT}:
|
|
paths.extend(_project_files(root, project_id))
|
|
return paths
|
|
|
|
|
|
def _all_observation_files(root: Path) -> list[Path]:
|
|
observation_root = root / OBSERVATION_DIR.lstrip("/")
|
|
try:
|
|
return sorted(path for path in observation_root.rglob("*.md") if path.is_file())
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _resolve_related_observations(
|
|
entries: list[RelatedObservationEntry],
|
|
*,
|
|
documents_by_id: dict[str, ObservationSearchDocument],
|
|
) -> tuple[RelatedObservationResult, ...]:
|
|
related_observations: list[RelatedObservationResult] = []
|
|
for entry in entries:
|
|
related_id = entry.id
|
|
if related_id not in documents_by_id:
|
|
continue
|
|
target = documents_by_id[related_id]
|
|
related: RelatedObservationResult = {
|
|
"observation_id": target.observation_id,
|
|
"path": target.path,
|
|
"memory_type": target.memory_type,
|
|
"scope": target.scope,
|
|
"summary": target.summary,
|
|
"relation": entry.relation,
|
|
"reason": entry.reason,
|
|
}
|
|
related_observations.append(related)
|
|
return tuple(related_observations)
|
|
|
|
|
|
def _parse_observation_search_document(
|
|
*,
|
|
root: Path,
|
|
path: Path,
|
|
) -> tuple[ObservationSearchDocument, list[RelatedObservationEntry]] | None:
|
|
document = _read_observation_document_with_text(path)
|
|
if document is None:
|
|
return None
|
|
metadata, body, text = document
|
|
try:
|
|
memory_path = "/" + path.relative_to(root).as_posix()
|
|
except ValueError:
|
|
return None
|
|
|
|
return (
|
|
ObservationSearchDocument(
|
|
observation_id=metadata.id,
|
|
path=_agent_path(memory_path),
|
|
memory_type=metadata.memory_type,
|
|
scope=metadata.scope,
|
|
summary=metadata.summary,
|
|
body=body,
|
|
text=text,
|
|
),
|
|
related_observation_entries(metadata),
|
|
)
|
|
|
|
|
|
def _resolve_document_links(
|
|
parsed: list[tuple[ObservationSearchDocument, list[RelatedObservationEntry]]],
|
|
*,
|
|
root: Path,
|
|
touched: set[str],
|
|
) -> list[ObservationSearchDocument]:
|
|
documents_by_id = {document.observation_id: document for document, _ in parsed}
|
|
missing_related_ids = {
|
|
entry.id
|
|
for _document, entries in parsed
|
|
for entry in entries
|
|
if entry.id not in documents_by_id
|
|
}
|
|
if missing_related_ids:
|
|
for path in _all_observation_files(root):
|
|
if not missing_related_ids:
|
|
break
|
|
parsed_document = _parse_with_cache(root, path, touched)
|
|
if parsed_document is None:
|
|
continue
|
|
document, _entries = parsed_document
|
|
if document.observation_id not in missing_related_ids:
|
|
continue
|
|
documents_by_id[document.observation_id] = document
|
|
missing_related_ids.remove(document.observation_id)
|
|
|
|
return [
|
|
replace(
|
|
document,
|
|
related_observations=_resolve_related_observations(
|
|
entries,
|
|
documents_by_id=documents_by_id,
|
|
),
|
|
)
|
|
for document, entries in parsed
|
|
]
|
|
|
|
|
|
# ── Parsed-document cache ─────────────────────────────────────────────
|
|
#
|
|
# One layer: a per-file parse cache keyed on the observation file path.
|
|
# Each value carries the file's ``(st_mtime_ns, st_size)`` signature, so a
|
|
# rewrite invalidates exactly that entry and replaces it in place. Scope
|
|
# filtering is just which directories get globbed, and link resolution
|
|
# reruns on every call over the memoized parses — its fallback walk parses
|
|
# through the same cache, so fallback visits are dict hits instead of
|
|
# whole-tree reparses.
|
|
#
|
|
# Deletion needs no invalidation: a deleted file is never globbed, so its
|
|
# cached entry can never be served again; the LRU bounds the leftover
|
|
# memory. Eviction runs only at the end of a call, down to
|
|
# ``max(cap, entries touched by the call)``, so a call never evicts its own
|
|
# working set and a store larger than the cap temporarily exceeds it
|
|
# instead of thrashing.
|
|
#
|
|
# The cap is ``config.memory_observation_cache_max_files`` (default 2048,
|
|
# env var ``EVOSCIENTIST_MAX_CACHED_FILES``), read lazily from config on
|
|
# first call. With the working-set rule it bounds retained memory for
|
|
# inactive workspaces rather than correctness.
|
|
#
|
|
_FileParseValue = tuple[
|
|
tuple[int, int],
|
|
tuple[ObservationSearchDocument, list[RelatedObservationEntry]],
|
|
]
|
|
|
|
_file_parse_cache: OrderedDict[str, _FileParseValue] = OrderedDict()
|
|
|
|
# Serializes cache transactions (lookup+recency, insert+recency, trim) so
|
|
# concurrent calls cannot evict a key between another call's lookup and its
|
|
# recency update. Parsing stays outside the lock; only dict mutations hold it.
|
|
_cache_lock = threading.Lock()
|
|
|
|
_cached_max_files: int | None = None
|
|
|
|
|
|
def _max_cached_files() -> int:
|
|
"""Return the configured cache cap.
|
|
|
|
Read once from ``get_effective_config()`` on first call and cached at
|
|
module level; a runtime config change requires a process restart to take
|
|
effect.
|
|
"""
|
|
global _cached_max_files
|
|
if _cached_max_files is None:
|
|
from ...config import get_effective_config
|
|
|
|
_cached_max_files = get_effective_config().memory_observation_cache_max_files
|
|
return _cached_max_files
|
|
|
|
|
|
def _parse_with_cache(
|
|
root: Path,
|
|
path: Path,
|
|
touched: set[str],
|
|
) -> tuple[ObservationSearchDocument, list[RelatedObservationEntry]] | None:
|
|
"""Return the parsed document for *path*, memoized per file.
|
|
|
|
The cached entry is validated against the file's current
|
|
``(st_mtime_ns, st_size)`` signature, so a rewrite replaces it in
|
|
place. Parse failures are not cached; the file is retried on the next
|
|
call. No copy on read or write: callers only iterate the documents or
|
|
build new ones via ``replace``.
|
|
"""
|
|
key = str(path)
|
|
try:
|
|
st = path.stat()
|
|
except OSError:
|
|
return None
|
|
signature = (st.st_mtime_ns, st.st_size)
|
|
with _cache_lock:
|
|
cached = _file_parse_cache.get(key)
|
|
if cached is not None and cached[0] == signature:
|
|
_file_parse_cache.move_to_end(key)
|
|
touched.add(key)
|
|
return cached[1]
|
|
parsed_document = _parse_observation_search_document(root=root, path=path)
|
|
if parsed_document is None:
|
|
return None
|
|
with _cache_lock:
|
|
_file_parse_cache[key] = (signature, parsed_document)
|
|
_file_parse_cache.move_to_end(key)
|
|
touched.add(key)
|
|
return parsed_document
|
|
|
|
|
|
def _trim_parse_cache(touched: set[str]) -> None:
|
|
"""Trim the cache at the end of a call down to ``max(cap, len(touched))``.
|
|
|
|
Every entry touched by the call was moved or appended to the
|
|
most-recent end, so trimming from the least-recent end down to at least
|
|
``len(touched)`` entries never evicts the call's own working set.
|
|
"""
|
|
target = max(_max_cached_files(), len(touched))
|
|
with _cache_lock:
|
|
while len(_file_parse_cache) > target:
|
|
_file_parse_cache.popitem(last=False)
|
|
|
|
|
|
def _global_files(root: Path) -> list[Path]:
|
|
"""Glob the global observation directory."""
|
|
directory = root / OBSERVATION_DIR.lstrip("/") / "global"
|
|
try:
|
|
return sorted(directory.glob("*.md"))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _project_files(root: Path, project_id: str) -> list[Path]:
|
|
"""Glob a project's observation directory."""
|
|
directory = root / OBSERVATION_DIR.lstrip("/") / "projects" / project_id
|
|
try:
|
|
return sorted(directory.glob("*.md"))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def list_observation_documents(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
scope: MemoryScope | None = None,
|
|
memory_type: MemoryType | None = None,
|
|
) -> list[ObservationSearchDocument]:
|
|
"""Read candidate observations for the current filters.
|
|
|
|
Observation files are parsed once per file and shared across every
|
|
project_id and scope; each cached entry is validated against the file's
|
|
current ``(st_mtime_ns, st_size)`` signature. A deleted file simply
|
|
drops out of the glob. Link resolution reruns on each call over the
|
|
memoized parses.
|
|
"""
|
|
root = Path(memory_dir).expanduser()
|
|
paths = _observation_files(memory_dir=root, project_id=project_id, scope=scope)
|
|
|
|
touched: set[str] = set()
|
|
parsed: list[tuple[ObservationSearchDocument, list[RelatedObservationEntry]]] = []
|
|
for path in paths:
|
|
parsed_document = _parse_with_cache(root, path, touched)
|
|
if parsed_document is not None:
|
|
parsed.append(parsed_document)
|
|
|
|
documents = _resolve_document_links(parsed, root=root, touched=touched)
|
|
_trim_parse_cache(touched)
|
|
|
|
if memory_type is not None:
|
|
return [
|
|
document for document in documents if document.memory_type == memory_type
|
|
]
|
|
return list(documents)
|
|
|
|
|
|
def search_observation_files(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
query: str,
|
|
scope: MemoryScope | None = None,
|
|
memory_type: MemoryType | None = None,
|
|
limit: int = 8,
|
|
mode: ObservationSearchMode = ObservationSearchMode.RANKED,
|
|
) -> list[ObservationSearchHit]:
|
|
"""Search global/current-project observations by ranked relevance by default."""
|
|
query_text = query.strip()
|
|
if not query_text:
|
|
return []
|
|
search_mode = ObservationSearchMode(mode)
|
|
|
|
documents = list_observation_documents(
|
|
memory_dir=memory_dir,
|
|
project_id=project_id,
|
|
scope=scope,
|
|
memory_type=memory_type,
|
|
)
|
|
return search_documents(
|
|
documents=documents,
|
|
query=query_text,
|
|
limit=limit,
|
|
mode=search_mode,
|
|
)
|
|
|
|
|
|
def read_observation_file(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
observation_id: str,
|
|
) -> ObservationReadResult | None:
|
|
"""Read a full observation document by frontmatter id."""
|
|
requested_id = observation_id.strip()
|
|
if not requested_id:
|
|
return None
|
|
|
|
root = Path(memory_dir).expanduser()
|
|
for document in list_observation_documents(
|
|
memory_dir=root,
|
|
project_id=project_id,
|
|
scope=None,
|
|
):
|
|
if document.observation_id != requested_id:
|
|
continue
|
|
result: ObservationReadResult = {
|
|
"observation_id": document.observation_id,
|
|
"path": document.path,
|
|
"memory_type": document.memory_type,
|
|
"scope": document.scope,
|
|
"summary": document.summary,
|
|
"text": document.text,
|
|
}
|
|
if document.related_observations:
|
|
result["related_observations"] = list(document.related_observations)
|
|
return result
|
|
return None
|
|
|
|
|
|
def observation_document_by_id(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
observation_id: str,
|
|
) -> tuple[Path, ObservationFrontmatter, str] | None:
|
|
"""Return the stored document tuple for one observation id."""
|
|
requested_id = observation_id.strip()
|
|
if not requested_id:
|
|
return None
|
|
|
|
root = Path(memory_dir).expanduser()
|
|
for path in _observation_files(
|
|
memory_dir=root,
|
|
project_id=project_id,
|
|
scope=None,
|
|
):
|
|
document = read_observation_document(path)
|
|
if document is None:
|
|
continue
|
|
metadata, body = document
|
|
if metadata.id == requested_id:
|
|
return path, metadata, body
|
|
return None
|
|
|
|
|
|
def _format_frontmatter(
|
|
*,
|
|
observation_id: str,
|
|
created_at: str,
|
|
memory_type: MemoryType,
|
|
summary: str,
|
|
scope: MemoryScope,
|
|
source_type: MemorySourceType,
|
|
source_agent: str,
|
|
source_session_id: str,
|
|
project_id: str,
|
|
) -> str:
|
|
"""Build the frontmatter block for an observation file."""
|
|
lines = [
|
|
"---",
|
|
f"id: {_json_string(observation_id)}",
|
|
f"created_at: {_json_string(created_at)}",
|
|
f"summary: {_json_string(summary)}",
|
|
f"memory_type: {memory_type.value}",
|
|
f"scope: {scope.value}",
|
|
]
|
|
if scope == MemoryScope.PROJECT:
|
|
lines.append(f"project_id: {_json_string(project_id)}")
|
|
lines.extend(
|
|
[
|
|
"source:",
|
|
f" type: {source_type.value}",
|
|
f" agent: {_json_string(source_agent)}",
|
|
]
|
|
)
|
|
lines.append(f" session_id: {_json_string(source_session_id.strip())}")
|
|
lines.append("---")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_observation_markdown(
|
|
*,
|
|
observation_id: str,
|
|
created_at: str,
|
|
memory_type: MemoryType,
|
|
summary: str,
|
|
observation: str,
|
|
why_it_matters: str,
|
|
evidence: str | None,
|
|
scope: MemoryScope,
|
|
source_type: MemorySourceType,
|
|
source_agent: str,
|
|
source_session_id: str,
|
|
project_id: str,
|
|
) -> str:
|
|
"""Render a complete observation markdown document."""
|
|
frontmatter = _format_frontmatter(
|
|
observation_id=observation_id,
|
|
created_at=created_at,
|
|
memory_type=memory_type,
|
|
summary=summary,
|
|
scope=scope,
|
|
source_type=source_type,
|
|
source_agent=source_agent,
|
|
source_session_id=source_session_id,
|
|
project_id=project_id,
|
|
)
|
|
body = (
|
|
f"{frontmatter}\n\n"
|
|
"## Observation\n\n"
|
|
f"{observation.strip()}\n\n"
|
|
"## Why It Matters\n\n"
|
|
f"{why_it_matters.strip()}\n"
|
|
)
|
|
if evidence and evidence.strip():
|
|
body += f"\n## Evidence\n\n{evidence.strip()}\n"
|
|
return body
|
|
|
|
|
|
def record_observation_file(
|
|
*,
|
|
memory_dir: str | Path,
|
|
project_id: str,
|
|
memory_type: MemoryType,
|
|
summary: str,
|
|
observation: str,
|
|
why_it_matters: str,
|
|
scope: MemoryScope,
|
|
source_type: MemorySourceType,
|
|
source_session_id: str,
|
|
source_agent: str,
|
|
evidence: str | None = None,
|
|
) -> ObservationRecordResult:
|
|
"""Create an observation markdown file unless an equivalent one exists.
|
|
|
|
The id is derived from the normalized observation text, rationale, type, and
|
|
scope, so repeated attempts to save the same observation return the existing
|
|
path instead of creating duplicates.
|
|
"""
|
|
|
|
summary_text = summary.strip()
|
|
observation_text = observation.strip()
|
|
why_text = why_it_matters.strip()
|
|
if not summary_text:
|
|
raise ValueError("summary must not be empty")
|
|
if not observation_text:
|
|
raise ValueError("observation must not be empty")
|
|
if not why_text:
|
|
raise ValueError("why_it_matters must not be empty")
|
|
if not source_session_id.strip():
|
|
raise ValueError("source_session_id must not be empty")
|
|
|
|
observation_id = _observation_id(
|
|
memory_type=memory_type,
|
|
scope=scope,
|
|
observation=observation_text,
|
|
why_it_matters=why_text,
|
|
)
|
|
memory_path = _memory_path(
|
|
observation_id=observation_id,
|
|
scope=scope,
|
|
project_id=project_id,
|
|
)
|
|
path = Path(memory_dir).expanduser() / memory_path.lstrip("/")
|
|
created = False
|
|
memory_root = Path(memory_dir).expanduser()
|
|
with FileLock(str(memory_root / ".observation-write.lock"), timeout=30):
|
|
if not path.exists():
|
|
created_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
content = _format_observation_markdown(
|
|
observation_id=observation_id,
|
|
created_at=created_at,
|
|
memory_type=memory_type,
|
|
summary=summary_text,
|
|
observation=observation_text,
|
|
why_it_matters=why_text,
|
|
evidence=evidence.strip() if evidence else None,
|
|
scope=scope,
|
|
source_type=source_type,
|
|
source_agent=source_agent,
|
|
source_session_id=source_session_id,
|
|
project_id=project_id,
|
|
)
|
|
_atomic_write_text(path, content)
|
|
created = True
|
|
|
|
result: ObservationRecordResult = {
|
|
"observation_id": observation_id,
|
|
"path": _agent_path(memory_path),
|
|
"created": created,
|
|
"memory_type": memory_type,
|
|
"scope": scope,
|
|
}
|
|
if scope == MemoryScope.PROJECT:
|
|
result["project_id"] = project_id
|
|
return result
|