memdate 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memdate/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """memdate — file-based memory with a hard CAPTURE/DISTILL split.
2
+
3
+ CAPTURE preserves raw records (append-only, timestamped, never
4
+ interpreted). DISTILL consolidates raw into derived distilled.md files
5
+ (never touching raw). INDEX regenerates the cross-domain connective
6
+ tissue after multi-domain distills.
7
+
8
+ Front-facing conversion of the ``memdate-v2`` skill (Ryan's
9
+ skill-suite, MIT). The original skill's Ryan-specific domains and Google
10
+ Drive root are configuration here, not defaults: domains come from
11
+ ``MemoryConfig`` (neutral defaults shipped), storage is a
12
+ ``StorageAdapter`` (local filesystem default, Drive an optional seam).
13
+ """
14
+
15
+ from .adapters import (
16
+ DriveNotConfigured,
17
+ GoogleDriveAdapter,
18
+ LocalFilesystemAdapter,
19
+ StorageAdapter,
20
+ StorageError,
21
+ default_adapter,
22
+ )
23
+ from .capture import (
24
+ CaptureError,
25
+ CaptureResult,
26
+ InterpretationError,
27
+ capture,
28
+ read_raw,
29
+ route,
30
+ scan_interpretation,
31
+ )
32
+ from .config import (
33
+ DEFAULT_DOMAINS,
34
+ ConfigError,
35
+ MemoryConfig,
36
+ )
37
+ from .distill import (
38
+ DistillError,
39
+ DistillResult,
40
+ distill,
41
+ read_distilled,
42
+ )
43
+ from .drop import SweepItem, ensure_layout, sweep_new
44
+ from .frontmatter import FrontmatterError
45
+ from .index import IndexError, IndexResult, read_index, regenerate_index
46
+
47
+ __all__ = [
48
+ "MemoryConfig",
49
+ "ConfigError",
50
+ "DEFAULT_DOMAINS",
51
+ "StorageAdapter",
52
+ "StorageError",
53
+ "LocalFilesystemAdapter",
54
+ "GoogleDriveAdapter",
55
+ "DriveNotConfigured",
56
+ "default_adapter",
57
+ "CaptureError",
58
+ "CaptureResult",
59
+ "InterpretationError",
60
+ "capture",
61
+ "read_raw",
62
+ "route",
63
+ "scan_interpretation",
64
+ "DistillError",
65
+ "DistillResult",
66
+ "distill",
67
+ "read_distilled",
68
+ "SweepItem",
69
+ "ensure_layout",
70
+ "sweep_new",
71
+ "FrontmatterError",
72
+ "IndexError",
73
+ "IndexResult",
74
+ "read_index",
75
+ "regenerate_index",
76
+ ]
77
+
78
+ __version__ = "1.0.0"
memdate/adapters.py ADDED
@@ -0,0 +1,174 @@
1
+ """Storage adapters.
2
+
3
+ The core library talks only to ``StorageAdapter``. The local filesystem
4
+ is the default. Google Drive — the original skill's primary location —
5
+ is an *optional* seam: ``GoogleDriveAdapter`` documents the interface and
6
+ fails loudly with setup instructions instead of pretending to work.
7
+
8
+ To wire a real backend, subclass ``StorageAdapter`` and pass it to the
9
+ operations. All paths are relative to the memory root, using "/" as the
10
+ separator.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import shutil
18
+ from abc import ABC, abstractmethod
19
+
20
+
21
+ class StorageError(OSError):
22
+ """Raised when the storage layer fails."""
23
+
24
+
25
+ class DriveNotConfigured(StorageError):
26
+ """Raised by the Google Drive adapter seam (see below)."""
27
+
28
+
29
+ class StorageAdapter(ABC):
30
+ """Minimal surface the memory operations need. Implement for a backend."""
31
+
32
+ @abstractmethod
33
+ def read(self, path: str) -> str | None:
34
+ """Return file text, or None if the file does not exist."""
35
+
36
+ @abstractmethod
37
+ def write(self, path: str, text: str) -> None:
38
+ """Create or overwrite a file."""
39
+
40
+ @abstractmethod
41
+ def append(self, path: str, text: str) -> None:
42
+ """Append text to a file, creating it if needed."""
43
+
44
+ @abstractmethod
45
+ def exists(self, path: str) -> bool:
46
+ """True if path exists (file or directory)."""
47
+
48
+ @abstractmethod
49
+ def makedirs(self, path: str) -> None:
50
+ """Create a directory and any missing parents."""
51
+
52
+ @abstractmethod
53
+ def move(self, src: str, dst: str) -> None:
54
+ """Move/rename a file, creating destination parents as needed."""
55
+
56
+ @abstractmethod
57
+ def list_files(self, path: str) -> list[str]:
58
+ """Names of files directly inside a directory (no recursion)."""
59
+
60
+ @abstractmethod
61
+ def digest(self, path: str) -> str:
62
+ """SHA-256 hex of a file's bytes. Used for the append-only check."""
63
+
64
+
65
+ class LocalFilesystemAdapter(StorageAdapter):
66
+ """Default adapter. A plain directory on the local filesystem."""
67
+
68
+ def __init__(self, root: str) -> None:
69
+ self.root = os.path.abspath(os.path.expanduser(root))
70
+
71
+ def _full(self, path: str) -> str:
72
+ full = os.path.abspath(os.path.join(self.root, *path.split("/")))
73
+ if full != self.root and not full.startswith(self.root + os.sep):
74
+ raise StorageError(f"Path escapes the memory root: {path!r}.")
75
+ return full
76
+
77
+ def read(self, path: str) -> str | None:
78
+ full = self._full(path)
79
+ if not os.path.isfile(full):
80
+ return None
81
+ with open(full, encoding="utf-8") as f:
82
+ return f.read()
83
+
84
+ def write(self, path: str, text: str) -> None:
85
+ full = self._full(path)
86
+ os.makedirs(os.path.dirname(full), exist_ok=True)
87
+ with open(full, "w", encoding="utf-8") as f:
88
+ f.write(text)
89
+
90
+ def append(self, path: str, text: str) -> None:
91
+ full = self._full(path)
92
+ os.makedirs(os.path.dirname(full), exist_ok=True)
93
+ with open(full, "a", encoding="utf-8") as f:
94
+ f.write(text)
95
+
96
+ def exists(self, path: str) -> bool:
97
+ return os.path.exists(self._full(path))
98
+
99
+ def makedirs(self, path: str) -> None:
100
+ os.makedirs(self._full(path), exist_ok=True)
101
+
102
+ def move(self, src: str, dst: str) -> None:
103
+ src_full, dst_full = self._full(src), self._full(dst)
104
+ os.makedirs(os.path.dirname(dst_full), exist_ok=True)
105
+ shutil.move(src_full, dst_full)
106
+
107
+ def list_files(self, path: str) -> list[str]:
108
+ full = self._full(path)
109
+ if not os.path.isdir(full):
110
+ return []
111
+ return sorted(
112
+ name for name in os.listdir(full)
113
+ if os.path.isfile(os.path.join(full, name))
114
+ )
115
+
116
+ def digest(self, path: str) -> str:
117
+ full = self._full(path)
118
+ h = hashlib.sha256()
119
+ with open(full, "rb") as f:
120
+ for chunk in iter(lambda: f.read(65536), b""):
121
+ h.update(chunk)
122
+ return h.hexdigest()
123
+
124
+
125
+ class GoogleDriveAdapter(StorageAdapter):
126
+ """Optional seam for a Google Drive backend. Not a real implementation.
127
+
128
+ The original skill stored everything in a Drive folder; this package
129
+ defaults to the local filesystem instead. If you want Drive, subclass
130
+ ``StorageAdapter`` and implement the seven methods — for example by
131
+ shelling out to the ``gws`` CLI or using the Drive API with your own
132
+ credentials. Instantiating this class and calling any method raises
133
+ ``DriveNotConfigured`` with a pointer, so a half-wired Drive setup
134
+ fails loudly instead of silently writing nowhere.
135
+ """
136
+
137
+ _MSG = (
138
+ "GoogleDriveAdapter is a seam, not an implementation: it has no "
139
+ "credentials and performs no I/O. To use Google Drive as the "
140
+ "memory root, subclass memdate.StorageAdapter and implement "
141
+ "read/write/append/exists/makedirs/move/list_files/digest against "
142
+ "the Drive API (or the gws CLI), then pass your adapter to the "
143
+ "capture/distill/index functions. Until then, LocalFilesystemAdapter "
144
+ "is the default and the only working backend."
145
+ )
146
+
147
+ def read(self, path: str) -> str | None:
148
+ raise DriveNotConfigured(self._MSG)
149
+
150
+ def write(self, path: str, text: str) -> None:
151
+ raise DriveNotConfigured(self._MSG)
152
+
153
+ def append(self, path: str, text: str) -> None:
154
+ raise DriveNotConfigured(self._MSG)
155
+
156
+ def exists(self, path: str) -> bool:
157
+ raise DriveNotConfigured(self._MSG)
158
+
159
+ def makedirs(self, path: str) -> None:
160
+ raise DriveNotConfigured(self._MSG)
161
+
162
+ def move(self, src: str, dst: str) -> None:
163
+ raise DriveNotConfigured(self._MSG)
164
+
165
+ def list_files(self, path: str) -> list[str]:
166
+ raise DriveNotConfigured(self._MSG)
167
+
168
+ def digest(self, path: str) -> str:
169
+ raise DriveNotConfigured(self._MSG)
170
+
171
+
172
+ def default_adapter(root: str) -> LocalFilesystemAdapter:
173
+ """The out-of-the-box backend: a local directory."""
174
+ return LocalFilesystemAdapter(root)
memdate/capture.py ADDED
@@ -0,0 +1,136 @@
1
+ """CAPTURE: append-only, timestamped raw records. Never interprets.
2
+
3
+ The isolation rule is enforced two ways:
4
+ 1. Structural: ``capture()`` only ever appends. It has no code path that
5
+ modifies, reorders, or deletes prior entries.
6
+ 2. Heuristic: the entry text is scanned for interpretation markers
7
+ (configurable; see ``config.MemoryConfig.interpretation_markers``).
8
+ A hit raises ``InterpretationError`` unless the caller passes
9
+ ``force=True``, in which case the bypass is recorded in the entry
10
+ header — the exception is visible, never silent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+
18
+ from .adapters import StorageAdapter, default_adapter
19
+ from .config import MemoryConfig
20
+
21
+
22
+ class CaptureError(ValueError):
23
+ """Base for capture failures."""
24
+
25
+
26
+ class InterpretationError(CaptureError):
27
+ """The entry looks like synthesis, not capture. Move it to DISTILL."""
28
+
29
+
30
+ ENTRY_HEADER_PREFIX = "## "
31
+
32
+
33
+ def scan_interpretation(text: str, markers: tuple[str, ...]) -> list[str]:
34
+ """Return the interpretation markers found in text (case-insensitive)."""
35
+ lowered = text.lower()
36
+ return [m for m in markers if m.lower() in lowered]
37
+
38
+
39
+ def stamp(now: datetime | None = None) -> str:
40
+ dt = now or datetime.now().astimezone()
41
+ return dt.strftime("%Y-%m-%d %H:%M %Z").strip()
42
+
43
+
44
+ @dataclass
45
+ class CaptureResult:
46
+ domain: str
47
+ path: str
48
+ timestamp: str
49
+ bytes_written: int
50
+ bypassed_check: bool
51
+
52
+
53
+ def capture(
54
+ config: MemoryConfig,
55
+ domain: str,
56
+ text: str,
57
+ *,
58
+ source: str = "",
59
+ now: datetime | None = None,
60
+ force: bool = False,
61
+ adapter: StorageAdapter | None = None,
62
+ ) -> CaptureResult:
63
+ """Append one raw record to ``<domain>/raw.md``.
64
+
65
+ Raises ``InterpretationError`` if the text trips an interpretation
66
+ marker (unless ``force=True``), ``CaptureError`` on empty text or an
67
+ unknown domain. Never touches any other file.
68
+ """
69
+ name = config.require_domain(domain)
70
+ body = text.strip()
71
+ if not body:
72
+ raise CaptureError("Capture text must not be empty.")
73
+
74
+ hits = scan_interpretation(body, config.interpretation_markers)
75
+ if hits and not force:
76
+ raise InterpretationError(
77
+ f"Entry looks like interpretation, not capture — markers found: "
78
+ f"{', '.join(hits)}. Rewrite as raw record, or pass force=True "
79
+ f"to record the bypass explicitly."
80
+ )
81
+
82
+ ts = stamp(now)
83
+ header = f"{ENTRY_HEADER_PREFIX}{ts}"
84
+ if source.strip():
85
+ header += f" — source: {source.strip()}"
86
+ if hits:
87
+ header += " — [interpretation-check bypassed: " + ", ".join(hits) + "]"
88
+ entry = f"{header}\n\n{body}\n\n"
89
+
90
+ store = adapter or default_adapter(config.root)
91
+ path = f"{name}/raw.md"
92
+ existed = store.exists(path)
93
+ store.append(path, ("\n" if existed and not _ends_blank(store, path) else "") + entry)
94
+ return CaptureResult(
95
+ domain=name,
96
+ path=path,
97
+ timestamp=ts,
98
+ bytes_written=len(entry.encode("utf-8")),
99
+ bypassed_check=bool(hits),
100
+ )
101
+
102
+
103
+ def _ends_blank(store: StorageAdapter, path: str) -> bool:
104
+ text = store.read(path) or ""
105
+ return text.endswith("\n\n") or not text
106
+
107
+
108
+ def read_raw(
109
+ config: MemoryConfig,
110
+ domain: str,
111
+ *,
112
+ adapter: StorageAdapter | None = None,
113
+ ) -> str | None:
114
+ """Return a domain's raw.md, or None if it has no captures yet."""
115
+ name = config.require_domain(domain)
116
+ store = adapter or default_adapter(config.root)
117
+ return store.read(f"{name}/raw.md")
118
+
119
+
120
+ def route(kind: str) -> str:
121
+ """The decision rule as a dispatch: recording→capture, consolidating→distill.
122
+
123
+ ``kind`` must be exactly "capture" or "distill". Anything else —
124
+ including "both" — raises, because the skill forbids performing both
125
+ implicitly in one operation.
126
+ """
127
+ normalized = kind.strip().lower()
128
+ if normalized == "capture":
129
+ return "capture"
130
+ if normalized == "distill":
131
+ return "distill"
132
+ raise CaptureError(
133
+ f"Unknown operation {kind!r}: use 'capture' for recording new "
134
+ f"information, 'distill' for consolidating existing information. "
135
+ f"Never both in one operation."
136
+ )
memdate/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """Configuration: memory root, domain list, and capture discipline.
2
+
3
+ The domain list is the main decoupling point of the whole package.
4
+ Ryan's original skill shipped with his domains (personal, fhk, tribunal,
5
+ memory-system, misc). This package ships with neutral defaults and lets
6
+ the caller define their own — via constructor, JSON, or a small YAML
7
+ subset (top-level keys with scalar or list values only).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+
15
+ # Neutral defaults. Nothing Ryan-specific ships here.
16
+ DEFAULT_DOMAINS = ("personal", "work", "projects", "reference", "misc")
17
+
18
+ # Heuristic markers of interpretation inside a capture entry.
19
+ # CAPTURE preserves; these phrases suggest the entry is synthesizing.
20
+ DEFAULT_INTERPRETATION_MARKERS = (
21
+ "in summary",
22
+ "in conclusion",
23
+ "to summarize",
24
+ "overall,",
25
+ "the takeaway",
26
+ "the bottom line",
27
+ "what this means is",
28
+ "in other words,",
29
+ "therefore we should",
30
+ "we should therefore",
31
+ )
32
+
33
+ DROP_SUBDIRS = ("NEW", "PROCESSED", "PENDING", "QUARANTINE")
34
+
35
+
36
+ class ConfigError(ValueError):
37
+ """Raised for invalid configuration."""
38
+
39
+
40
+ @dataclass
41
+ class MemoryConfig:
42
+ """Everything the library needs to find and govern a memory store."""
43
+
44
+ root: str
45
+ domains: tuple[str, ...] = DEFAULT_DOMAINS
46
+ interpretation_markers: tuple[str, ...] = DEFAULT_INTERPRETATION_MARKERS
47
+ drop_subdirs: tuple[str, ...] = DROP_SUBDIRS
48
+
49
+ def __post_init__(self) -> None:
50
+ if not self.root:
51
+ raise ConfigError("Memory root must not be empty.")
52
+ if not self.domains:
53
+ raise ConfigError("Domain list must not be empty.")
54
+ seen: set[str] = set()
55
+ for d in self.domains:
56
+ name = d.strip()
57
+ if not name:
58
+ raise ConfigError("Domain names must not be blank.")
59
+ if "/" in name or "\\" in name or name in (".", ".."):
60
+ raise ConfigError(f"Invalid domain name: {d!r}.")
61
+ if name.lower() in seen:
62
+ raise ConfigError(f"Duplicate domain: {name!r}.")
63
+ seen.add(name.lower())
64
+ self.domains = tuple(d.strip() for d in self.domains)
65
+
66
+ @classmethod
67
+ def from_dict(cls, data: dict) -> "MemoryConfig":
68
+ root = data.get("root")
69
+ if not root:
70
+ raise ConfigError("Config needs a 'root' key.")
71
+ return cls(
72
+ root=root,
73
+ domains=tuple(data.get("domains", DEFAULT_DOMAINS)),
74
+ interpretation_markers=tuple(
75
+ data.get("interpretation_markers", DEFAULT_INTERPRETATION_MARKERS)
76
+ ),
77
+ )
78
+
79
+ @classmethod
80
+ def from_json(cls, path: str) -> "MemoryConfig":
81
+ with open(path, encoding="utf-8") as f:
82
+ data = json.load(f)
83
+ if not isinstance(data, dict):
84
+ raise ConfigError(f"{path}: top-level JSON value must be an object.")
85
+ return cls.from_dict(data)
86
+
87
+ @classmethod
88
+ def from_yaml(cls, path: str) -> "MemoryConfig":
89
+ """Parse a strict, tiny YAML subset.
90
+
91
+ Supported: top-level ``key: value`` pairs and ``key:`` followed by
92
+ ``- item`` list lines. Comments (#) and blank lines ignored.
93
+ Anything else raises ConfigError. This is deliberately not a
94
+ general YAML parser — stdlib has none, and a memory config
95
+ doesn't need one.
96
+ """
97
+ data: dict[str, object] = {}
98
+ current_key: str | None = None
99
+ with open(path, encoding="utf-8") as f:
100
+ for lineno, raw in enumerate(f, 1):
101
+ line = raw.split("#", 1)[0].rstrip()
102
+ if not line.strip():
103
+ continue
104
+ if line != line.lstrip():
105
+ stripped = line.strip()
106
+ if stripped.startswith("- ") and current_key is not None:
107
+ item = stripped[2:].strip().strip("'\"")
108
+ lst = data.get(current_key)
109
+ if not isinstance(lst, list):
110
+ raise ConfigError(
111
+ f"{path}:{lineno}: list item without a list key."
112
+ )
113
+ lst.append(item)
114
+ continue
115
+ raise ConfigError(
116
+ f"{path}:{lineno}: unsupported indented line: {raw.rstrip()!r}."
117
+ )
118
+ if ":" not in line:
119
+ raise ConfigError(
120
+ f"{path}:{lineno}: expected 'key: value': {raw.rstrip()!r}."
121
+ )
122
+ key, _, value = line.partition(":")
123
+ key = key.strip()
124
+ value = value.strip().strip("'\"")
125
+ if not key:
126
+ raise ConfigError(f"{path}:{lineno}: blank key.")
127
+ if value:
128
+ data[key] = value
129
+ current_key = None
130
+ else:
131
+ data[key] = []
132
+ current_key = key
133
+ return cls.from_dict(data)
134
+
135
+ def domain_dir(self, domain: str) -> str:
136
+ """Relative path of a domain's directory inside the root."""
137
+ self.require_domain(domain)
138
+ return domain
139
+
140
+ def require_domain(self, domain: str) -> str:
141
+ name = domain.strip()
142
+ known = {d.lower(): d for d in self.domains}
143
+ if name.lower() not in known:
144
+ raise ConfigError(
145
+ f"Unknown domain {domain!r}. Known domains: "
146
+ f"{', '.join(self.domains)}."
147
+ )
148
+ return known[name.lower()]
memdate/distill.py ADDED
@@ -0,0 +1,142 @@
1
+ """DISTILL: consolidate raw into derived distilled.md. Never touches raw.
2
+
3
+ The caller (human or model) supplies the consolidated body; the library
4
+ writes it with provenance frontmatter and enforces the isolation rule by
5
+ checksumming raw.md before and after the write. If raw changed in
6
+ between, the run fails — DISTILL never alters raw, and it refuses to
7
+ certify a run where something else did.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import date
14
+
15
+ from . import frontmatter as fm
16
+ from .adapters import StorageAdapter, default_adapter
17
+ from .config import MemoryConfig
18
+
19
+ ARTIFACT = "memdate-distilled"
20
+ PROTOCOL_VERSION = "memdate/1.0.0"
21
+
22
+ LIFECYCLES = ("INITIAL", "ITERATIVE", "FINAL")
23
+ # FINAL only if the domain's raw record is fully consolidated with no open threads.
24
+
25
+
26
+ class DistillError(ValueError):
27
+ """Raised when a distill run breaks the isolation rules."""
28
+
29
+
30
+ @dataclass
31
+ class DistillResult:
32
+ domain: str
33
+ path: str
34
+ lifecycle: str
35
+ raw_untouched: bool
36
+ entities: tuple[str, ...] = ()
37
+ open_questions: tuple[str, ...] = ()
38
+
39
+
40
+ def distill(
41
+ config: MemoryConfig,
42
+ domain: str,
43
+ body: str,
44
+ *,
45
+ entities: list[str] | None = None,
46
+ entity_notes: dict[str, str] | None = None,
47
+ informs: list[str] | None = None,
48
+ open_questions: list[str] | None = None,
49
+ lifecycle: str = "ITERATIVE",
50
+ today: str | None = None,
51
+ adapter: StorageAdapter | None = None,
52
+ ) -> DistillResult:
53
+ """Write ``<domain>/distilled.md`` with provenance frontmatter.
54
+
55
+ - ``body`` is the consolidated synthesis (supplied by the caller).
56
+ - ``entities`` names people/places/concepts this distillation
57
+ references; the cross-domain index uses them. ``entity_notes`` maps
58
+ an entity to a one-line status note.
59
+ - ``informs`` lists other domains this domain's material informs
60
+ (dependency direction).
61
+ - ``open_questions`` are contradictions/unknowns visible in this domain.
62
+ - Raises ``DistillError`` if raw.md is missing (nothing to distill),
63
+ if lifecycle is invalid, or if raw.md's bytes changed during the run.
64
+ """
65
+ name = config.require_domain(domain)
66
+ text = body.strip()
67
+ if not text:
68
+ raise DistillError("Distilled body must not be empty.")
69
+ cycle = lifecycle.strip().upper()
70
+ if cycle not in LIFECYCLES:
71
+ raise DistillError(
72
+ f"Invalid lifecycle {lifecycle!r}. Use one of: {', '.join(LIFECYCLES)}."
73
+ )
74
+ if informs:
75
+ for other in informs:
76
+ config.require_domain(other)
77
+
78
+ store = adapter or default_adapter(config.root)
79
+ raw_path = f"{name}/raw.md"
80
+ raw_text = store.read(raw_path)
81
+ if raw_text is None:
82
+ raise DistillError(
83
+ f"Cannot distill domain {name!r}: no raw.md exists. "
84
+ f"Capture something first."
85
+ )
86
+ raw_before = store.digest(raw_path)
87
+
88
+ fields: dict[str, object] = {
89
+ "artifact": ARTIFACT,
90
+ "domain": name,
91
+ "date": today or date.today().isoformat(),
92
+ "protocol": PROTOCOL_VERSION,
93
+ "lifecycle": cycle,
94
+ "entities": [e.strip() for e in (entities or []) if e.strip()],
95
+ "informs": [config.require_domain(d) for d in (informs or [])],
96
+ "open_questions": [q.strip() for q in (open_questions or []) if q.strip()],
97
+ }
98
+ if entity_notes:
99
+ fields["entity_notes"] = {k.strip(): v.strip() for k, v in entity_notes.items()}
100
+
101
+ document = fm.render(fields) + text.rstrip() + "\n"
102
+ distilled_path = f"{name}/distilled.md"
103
+ store.write(distilled_path, document)
104
+
105
+ raw_after = store.digest(raw_path)
106
+ if raw_after != raw_before:
107
+ raise DistillError(
108
+ f"Isolation violation: {raw_path} changed during the distill run. "
109
+ f"DISTILL never alters raw — the run is void; inspect what wrote to raw."
110
+ )
111
+
112
+ return DistillResult(
113
+ domain=name,
114
+ path=distilled_path,
115
+ lifecycle=cycle,
116
+ raw_untouched=True,
117
+ entities=tuple(fields["entities"]), # type: ignore[arg-type]
118
+ open_questions=tuple(fields["open_questions"]), # type: ignore[arg-type]
119
+ )
120
+
121
+
122
+ def read_distilled(
123
+ config: MemoryConfig,
124
+ domain: str,
125
+ *,
126
+ adapter: StorageAdapter | None = None,
127
+ ) -> tuple[dict[str, object], str] | None:
128
+ """Return ``(frontmatter, body)`` for a domain's distilled.md, or None."""
129
+ name = config.require_domain(domain)
130
+ store = adapter or default_adapter(config.root)
131
+ text = store.read(f"{name}/distilled.md")
132
+ if text is None:
133
+ return None
134
+ fields, body = fm.split(text)
135
+ fm.require(fields, "artifact", "domain", "date", "protocol", "lifecycle",
136
+ what=f"distilled.md for {name!r}")
137
+ if fields.get("artifact") != ARTIFACT:
138
+ raise DistillError(
139
+ f"distilled.md for {name!r} has artifact {fields.get('artifact')!r}, "
140
+ f"expected {ARTIFACT!r}."
141
+ )
142
+ return fields, body