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 +78 -0
- memdate/adapters.py +174 -0
- memdate/capture.py +136 -0
- memdate/config.py +148 -0
- memdate/distill.py +142 -0
- memdate/drop.py +116 -0
- memdate/frontmatter.py +108 -0
- memdate/index.py +264 -0
- memdate-1.0.0.dist-info/METADATA +210 -0
- memdate-1.0.0.dist-info/RECORD +13 -0
- memdate-1.0.0.dist-info/WHEEL +5 -0
- memdate-1.0.0.dist-info/licenses/LICENSE +21 -0
- memdate-1.0.0.dist-info/top_level.txt +1 -0
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
|