memleaf 0.1.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.
- memleaf/__init__.py +35 -0
- memleaf/adapters/__init__.py +15 -0
- memleaf/adapters/antigravity.py +352 -0
- memleaf/adapters/base.py +746 -0
- memleaf/adapters/codex.py +460 -0
- memleaf/adapters/hermes.py +710 -0
- memleaf/budget.py +123 -0
- memleaf/capture.py +311 -0
- memleaf/cli.py +360 -0
- memleaf/compaction.py +878 -0
- memleaf/config.py +164 -0
- memleaf/frontmatter.py +403 -0
- memleaf/host_events.py +1327 -0
- memleaf/inbox.py +292 -0
- memleaf/index.py +275 -0
- memleaf/llm/__init__.py +43 -0
- memleaf/llm/base.py +349 -0
- memleaf/llm/claude_compatible.py +31 -0
- memleaf/llm/gemini.py +35 -0
- memleaf/llm/openai_compatible.py +127 -0
- memleaf/llm/router.py +172 -0
- memleaf/locking.py +134 -0
- memleaf/mcp_server.py +979 -0
- memleaf/memory_writer.py +437 -0
- memleaf/model_discovery.py +746 -0
- memleaf/models.py +285 -0
- memleaf/native_index.py +776 -0
- memleaf/processing.py +2029 -0
- memleaf/prompts.py +306 -0
- memleaf/redaction.py +41 -0
- memleaf/retrieval.py +377 -0
- memleaf/retrieval_gate.py +604 -0
- memleaf/scope_maintenance.py +480 -0
- memleaf/scope_state.py +328 -0
- memleaf/service.py +1420 -0
- memleaf/validation.py +794 -0
- memleaf/vault.py +247 -0
- memleaf-0.1.0.dist-info/METADATA +423 -0
- memleaf-0.1.0.dist-info/RECORD +43 -0
- memleaf-0.1.0.dist-info/WHEEL +5 -0
- memleaf-0.1.0.dist-info/entry_points.txt +3 -0
- memleaf-0.1.0.dist-info/licenses/LICENSE +21 -0
- memleaf-0.1.0.dist-info/top_level.txt +1 -0
memleaf/config.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Vault configuration using memleaf's restricted YAML subset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .frontmatter import FrontmatterError, dump_yaml, load_yaml
|
|
11
|
+
from .locking import atomic_write_text
|
|
12
|
+
from .native_index import NativeConfigError, validate_native_sources
|
|
13
|
+
from .scope_state import ScopeError, validate_scope_registry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_REQUEST_TIMEOUT = 120
|
|
17
|
+
MIN_REQUEST_TIMEOUT = 1
|
|
18
|
+
MAX_REQUEST_TIMEOUT = 240
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_request_timeout(value: Any) -> int | float:
|
|
22
|
+
if isinstance(value, bool):
|
|
23
|
+
raise ValueError("invalid memleaf llm.request_timeout")
|
|
24
|
+
try:
|
|
25
|
+
parsed = float(value)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
raise ValueError("invalid memleaf llm.request_timeout") from None
|
|
28
|
+
if not math.isfinite(parsed) or not MIN_REQUEST_TIMEOUT <= parsed <= MAX_REQUEST_TIMEOUT:
|
|
29
|
+
raise ValueError("invalid memleaf llm.request_timeout")
|
|
30
|
+
return int(parsed) if parsed.is_integer() else parsed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
34
|
+
"vault": "~/.memleaf",
|
|
35
|
+
"agents": {
|
|
36
|
+
"codex": True,
|
|
37
|
+
"hermes": True,
|
|
38
|
+
"antigravity": False,
|
|
39
|
+
},
|
|
40
|
+
"scopes": {},
|
|
41
|
+
"native_sources": {},
|
|
42
|
+
"process": {
|
|
43
|
+
"memory_compact_threshold_tokens": 100000,
|
|
44
|
+
"memory_compact_candidate_ratio": 0.30,
|
|
45
|
+
"inbox_cleanup_hours": 24,
|
|
46
|
+
},
|
|
47
|
+
"capture": {
|
|
48
|
+
"visible_messages_only": True,
|
|
49
|
+
"include_tool_output": False,
|
|
50
|
+
"include_attachments": False,
|
|
51
|
+
"redact_secrets": True,
|
|
52
|
+
},
|
|
53
|
+
"inject": {
|
|
54
|
+
"mode": "tag_full",
|
|
55
|
+
"abnormal_guard": True,
|
|
56
|
+
},
|
|
57
|
+
"llm": {
|
|
58
|
+
"mode": "auto",
|
|
59
|
+
"provider": "",
|
|
60
|
+
"protocol": "openai",
|
|
61
|
+
"base_url": "",
|
|
62
|
+
"api_key": "",
|
|
63
|
+
"api_key_env": "",
|
|
64
|
+
"model": "",
|
|
65
|
+
"context_window": 200000,
|
|
66
|
+
"request_timeout": DEFAULT_REQUEST_TIMEOUT,
|
|
67
|
+
"diagnostic_logging": False,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _merge_defaults(value: Mapping[str, Any], defaults: Mapping[str, Any]) -> dict[str, Any]:
|
|
73
|
+
merged: dict[str, Any] = deepcopy(dict(defaults))
|
|
74
|
+
for key, item in value.items():
|
|
75
|
+
if isinstance(item, Mapping) and isinstance(merged.get(key), Mapping):
|
|
76
|
+
merged[key] = _merge_defaults(item, merged[key])
|
|
77
|
+
else:
|
|
78
|
+
merged[key] = item
|
|
79
|
+
return merged
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_config(vault: Path | str | None = None) -> dict[str, Any]:
|
|
83
|
+
config = deepcopy(DEFAULT_CONFIG)
|
|
84
|
+
if vault is not None:
|
|
85
|
+
config["vault"] = str(Path(vault).expanduser())
|
|
86
|
+
return config
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_config(path: Path | str, *, vault: Path | str | None = None) -> dict[str, Any]:
|
|
90
|
+
config_path = Path(path)
|
|
91
|
+
if not config_path.exists():
|
|
92
|
+
return default_config(vault)
|
|
93
|
+
try:
|
|
94
|
+
parsed = load_yaml(config_path.read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, FrontmatterError) as error:
|
|
96
|
+
raise ValueError("invalid memleaf config.yaml") from error
|
|
97
|
+
if not isinstance(parsed, dict):
|
|
98
|
+
raise ValueError("invalid memleaf config.yaml")
|
|
99
|
+
merged = _merge_defaults(parsed, default_config(vault))
|
|
100
|
+
if not isinstance(merged.get("vault"), str):
|
|
101
|
+
raise ValueError("invalid memleaf vault setting")
|
|
102
|
+
capture = merged.get("capture")
|
|
103
|
+
if not isinstance(capture, Mapping) or not isinstance(capture.get("redact_secrets"), bool):
|
|
104
|
+
raise ValueError("invalid memleaf capture settings")
|
|
105
|
+
process = merged.get("process")
|
|
106
|
+
threshold = process.get("memory_compact_threshold_tokens") if isinstance(process, Mapping) else None
|
|
107
|
+
ratio = process.get("memory_compact_candidate_ratio") if isinstance(process, Mapping) else None
|
|
108
|
+
if type(threshold) is not int or threshold <= 0:
|
|
109
|
+
raise ValueError("invalid memleaf process.memory_compact_threshold_tokens")
|
|
110
|
+
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)) or not math.isfinite(float(ratio)):
|
|
111
|
+
raise ValueError("invalid memleaf process.memory_compact_candidate_ratio")
|
|
112
|
+
if not 0 < float(ratio) <= 1:
|
|
113
|
+
raise ValueError("invalid memleaf process.memory_compact_candidate_ratio")
|
|
114
|
+
cleanup_hours = process.get("inbox_cleanup_hours") if isinstance(process, Mapping) else None
|
|
115
|
+
if type(cleanup_hours) is not int or cleanup_hours < 0:
|
|
116
|
+
raise ValueError("invalid memleaf process.inbox_cleanup_hours")
|
|
117
|
+
llm = merged.get("llm")
|
|
118
|
+
if not isinstance(llm, Mapping):
|
|
119
|
+
raise ValueError("invalid memleaf llm settings")
|
|
120
|
+
llm = dict(llm)
|
|
121
|
+
llm["request_timeout"] = _normalize_request_timeout(llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT))
|
|
122
|
+
if type(llm.get("diagnostic_logging", False)) is not bool:
|
|
123
|
+
raise ValueError("invalid memleaf llm.diagnostic_logging")
|
|
124
|
+
merged["llm"] = llm
|
|
125
|
+
try:
|
|
126
|
+
merged["scopes"] = validate_scope_registry(merged.get("scopes", {}))
|
|
127
|
+
except ScopeError as error:
|
|
128
|
+
raise ValueError("invalid memleaf scopes registry") from error
|
|
129
|
+
try:
|
|
130
|
+
validate_native_sources(merged.get("native_sources", {}), base_dir=config_path.parent)
|
|
131
|
+
except NativeConfigError as error:
|
|
132
|
+
raise ValueError("invalid memleaf native_sources") from error
|
|
133
|
+
return merged
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_config(path: Path | str, config: Mapping[str, Any]) -> None:
|
|
137
|
+
if not isinstance(config, Mapping):
|
|
138
|
+
raise ValueError("config must be a mapping")
|
|
139
|
+
normalized = deepcopy(dict(config))
|
|
140
|
+
try:
|
|
141
|
+
normalized["scopes"] = validate_scope_registry(normalized.get("scopes", {}))
|
|
142
|
+
except ScopeError as error:
|
|
143
|
+
raise ValueError("invalid memleaf scopes registry") from error
|
|
144
|
+
try:
|
|
145
|
+
validate_native_sources(normalized.get("native_sources", {}), base_dir=Path(path).parent)
|
|
146
|
+
except NativeConfigError as error:
|
|
147
|
+
raise ValueError("invalid memleaf native_sources") from error
|
|
148
|
+
llm = normalized.get("llm")
|
|
149
|
+
if not isinstance(llm, Mapping):
|
|
150
|
+
raise ValueError("invalid memleaf llm settings")
|
|
151
|
+
normalized_llm = dict(llm)
|
|
152
|
+
normalized_llm["request_timeout"] = _normalize_request_timeout(
|
|
153
|
+
normalized_llm.get("request_timeout", DEFAULT_REQUEST_TIMEOUT)
|
|
154
|
+
)
|
|
155
|
+
diagnostic_logging = normalized_llm.get("diagnostic_logging", False)
|
|
156
|
+
if type(diagnostic_logging) is not bool:
|
|
157
|
+
raise ValueError("invalid memleaf llm.diagnostic_logging")
|
|
158
|
+
normalized_llm["diagnostic_logging"] = diagnostic_logging
|
|
159
|
+
normalized["llm"] = normalized_llm
|
|
160
|
+
try:
|
|
161
|
+
text = dump_yaml(normalized)
|
|
162
|
+
except FrontmatterError as error:
|
|
163
|
+
raise ValueError("config cannot be serialized") from error
|
|
164
|
+
atomic_write_text(Path(path), text)
|
memleaf/frontmatter.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""A deliberately small YAML subset used by config and Markdown frontmatter.
|
|
2
|
+
|
|
3
|
+
The project is standard-library-only. This parser intentionally supports only
|
|
4
|
+
the predictable subset memleaf writes: mappings, block lists, inline scalar
|
|
5
|
+
lists, strings, numbers, booleans, and null. It rejects YAML features whose
|
|
6
|
+
semantics would be surprising without a full YAML implementation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Iterable, List, Mapping, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FrontmatterError(ValueError):
|
|
18
|
+
"""Raised when the restricted YAML/Markdown format is invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class _Line:
|
|
23
|
+
number: int
|
|
24
|
+
indent: int
|
|
25
|
+
content: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _error(number: int, message: str) -> FrontmatterError:
|
|
29
|
+
return FrontmatterError(f"invalid restricted YAML at line {number}: {message}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _strip_comment(value: str) -> str:
|
|
33
|
+
quote = None
|
|
34
|
+
escaped = False
|
|
35
|
+
for index, character in enumerate(value):
|
|
36
|
+
if quote == '"' and escaped:
|
|
37
|
+
escaped = False
|
|
38
|
+
continue
|
|
39
|
+
if quote == '"' and character == "\\":
|
|
40
|
+
escaped = True
|
|
41
|
+
continue
|
|
42
|
+
if character in ('"', "'"):
|
|
43
|
+
if quote is None:
|
|
44
|
+
quote = character
|
|
45
|
+
elif quote == character:
|
|
46
|
+
quote = None
|
|
47
|
+
continue
|
|
48
|
+
if character == "#" and quote is None:
|
|
49
|
+
if index == 0 or value[index - 1].isspace():
|
|
50
|
+
return value[:index].rstrip()
|
|
51
|
+
return value.rstrip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prepare_lines(text: str) -> List[_Line]:
|
|
55
|
+
result: List[_Line] = []
|
|
56
|
+
for number, raw in enumerate(text.splitlines(), start=1):
|
|
57
|
+
if raw.startswith("\ufeff"):
|
|
58
|
+
raw = raw[1:]
|
|
59
|
+
leading = len(raw) - len(raw.lstrip(" "))
|
|
60
|
+
if "\t" in raw[:leading]:
|
|
61
|
+
raise _error(number, "tabs are not allowed for indentation")
|
|
62
|
+
if leading % 2:
|
|
63
|
+
raise _error(number, "indentation must use two-space levels")
|
|
64
|
+
content = _strip_comment(raw[leading:]).strip()
|
|
65
|
+
if not content:
|
|
66
|
+
continue
|
|
67
|
+
if content in ("---", "..."):
|
|
68
|
+
raise _error(number, "document markers are not allowed inside a value")
|
|
69
|
+
result.append(_Line(number, leading, content))
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_colon(value: str) -> int:
|
|
74
|
+
quote = None
|
|
75
|
+
escaped = False
|
|
76
|
+
for index, character in enumerate(value):
|
|
77
|
+
if quote == '"' and escaped:
|
|
78
|
+
escaped = False
|
|
79
|
+
continue
|
|
80
|
+
if quote == '"' and character == "\\":
|
|
81
|
+
escaped = True
|
|
82
|
+
continue
|
|
83
|
+
if character in ('"', "'"):
|
|
84
|
+
if quote is None:
|
|
85
|
+
quote = character
|
|
86
|
+
elif quote == character:
|
|
87
|
+
quote = None
|
|
88
|
+
continue
|
|
89
|
+
if character == ":" and quote is None and (
|
|
90
|
+
index + 1 == len(value) or value[index + 1].isspace()
|
|
91
|
+
):
|
|
92
|
+
return index
|
|
93
|
+
return -1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _split_flow_items(value: str, number: int) -> List[str]:
|
|
97
|
+
if not (value.startswith("[") and value.endswith("]")):
|
|
98
|
+
raise _error(number, "invalid inline list")
|
|
99
|
+
inner = value[1:-1].strip()
|
|
100
|
+
if not inner:
|
|
101
|
+
return []
|
|
102
|
+
items: List[str] = []
|
|
103
|
+
start = 0
|
|
104
|
+
quote = None
|
|
105
|
+
escaped = False
|
|
106
|
+
for index, character in enumerate(inner):
|
|
107
|
+
if quote == '"' and escaped:
|
|
108
|
+
escaped = False
|
|
109
|
+
continue
|
|
110
|
+
if quote == '"' and character == "\\":
|
|
111
|
+
escaped = True
|
|
112
|
+
continue
|
|
113
|
+
if character in ('"', "'"):
|
|
114
|
+
if quote is None:
|
|
115
|
+
quote = character
|
|
116
|
+
elif quote == character:
|
|
117
|
+
quote = None
|
|
118
|
+
continue
|
|
119
|
+
if character == "," and quote is None:
|
|
120
|
+
item = inner[start:index].strip()
|
|
121
|
+
if not item:
|
|
122
|
+
raise _error(number, "empty inline-list item")
|
|
123
|
+
items.append(item)
|
|
124
|
+
start = index + 1
|
|
125
|
+
if quote is not None:
|
|
126
|
+
raise _error(number, "unterminated quoted value")
|
|
127
|
+
item = inner[start:].strip()
|
|
128
|
+
if not item:
|
|
129
|
+
raise _error(number, "empty inline-list item")
|
|
130
|
+
items.append(item)
|
|
131
|
+
return items
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_scalar(value: str, number: int) -> Any:
|
|
135
|
+
value = value.strip()
|
|
136
|
+
if not value:
|
|
137
|
+
return None
|
|
138
|
+
if value.startswith("["):
|
|
139
|
+
if not value.endswith("]"):
|
|
140
|
+
raise _error(number, "unterminated inline list")
|
|
141
|
+
return [_parse_scalar(item, number) for item in _split_flow_items(value, number)]
|
|
142
|
+
if value.startswith("{") or value in ("|", ">") or value.startswith(("&", "*", "!")):
|
|
143
|
+
raise _error(number, "unsupported YAML feature")
|
|
144
|
+
if value.startswith('"'):
|
|
145
|
+
if not value.endswith('"'):
|
|
146
|
+
raise _error(number, "unterminated double-quoted string")
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(value)
|
|
149
|
+
except (TypeError, json.JSONDecodeError):
|
|
150
|
+
raise _error(number, "invalid double-quoted string")
|
|
151
|
+
if value.startswith("'"):
|
|
152
|
+
if not value.endswith("'"):
|
|
153
|
+
raise _error(number, "unterminated single-quoted string")
|
|
154
|
+
return value[1:-1].replace("''", "'")
|
|
155
|
+
lowered = value.casefold()
|
|
156
|
+
if lowered in ("true", "false"):
|
|
157
|
+
return lowered == "true"
|
|
158
|
+
if lowered in ("null", "~"):
|
|
159
|
+
return None
|
|
160
|
+
if re.fullmatch(r"[-+]?\d+", value):
|
|
161
|
+
try:
|
|
162
|
+
return int(value)
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise _error(number, "invalid integer")
|
|
165
|
+
if re.fullmatch(r"[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?", value) or re.fullmatch(
|
|
166
|
+
r"[-+]?\d+[eE][-+]?\d+", value
|
|
167
|
+
):
|
|
168
|
+
try:
|
|
169
|
+
return float(value)
|
|
170
|
+
except ValueError:
|
|
171
|
+
raise _error(number, "invalid number")
|
|
172
|
+
if "\n" in value or "\r" in value:
|
|
173
|
+
raise _error(number, "multiline values are not allowed")
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Parser:
|
|
178
|
+
def __init__(self, lines: Sequence[_Line]):
|
|
179
|
+
self.lines = lines
|
|
180
|
+
|
|
181
|
+
def parse(self) -> Any:
|
|
182
|
+
if not self.lines:
|
|
183
|
+
return {}
|
|
184
|
+
if self.lines[0].indent != 0:
|
|
185
|
+
raise _error(self.lines[0].number, "top-level indentation is not allowed")
|
|
186
|
+
value, index = self._block(0, 0)
|
|
187
|
+
if index != len(self.lines):
|
|
188
|
+
line = self.lines[index]
|
|
189
|
+
raise _error(line.number, "unexpected indentation")
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
def _block(self, index: int, indent: int) -> Tuple[Any, int]:
|
|
193
|
+
if index >= len(self.lines):
|
|
194
|
+
return {}, index
|
|
195
|
+
line = self.lines[index]
|
|
196
|
+
if line.indent != indent:
|
|
197
|
+
raise _error(line.number, "unexpected indentation")
|
|
198
|
+
if line.content == "-" or line.content.startswith("- "):
|
|
199
|
+
return self._list(index, indent)
|
|
200
|
+
return self._mapping(index, indent)
|
|
201
|
+
|
|
202
|
+
def _mapping(self, index: int, indent: int) -> Tuple[dict[str, Any], int]:
|
|
203
|
+
result: dict[str, Any] = {}
|
|
204
|
+
while index < len(self.lines):
|
|
205
|
+
line = self.lines[index]
|
|
206
|
+
if line.indent < indent:
|
|
207
|
+
break
|
|
208
|
+
if line.indent > indent:
|
|
209
|
+
raise _error(line.number, "unexpected indentation")
|
|
210
|
+
if line.content == "-" or line.content.startswith("- "):
|
|
211
|
+
break
|
|
212
|
+
key, raw_value = self._mapping_entry(line)
|
|
213
|
+
if key in result:
|
|
214
|
+
raise _error(line.number, "duplicate key")
|
|
215
|
+
index += 1
|
|
216
|
+
if raw_value == "":
|
|
217
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
218
|
+
child_indent = self.lines[index].indent
|
|
219
|
+
value, index = self._block(index, child_indent)
|
|
220
|
+
else:
|
|
221
|
+
value = {}
|
|
222
|
+
else:
|
|
223
|
+
value = _parse_scalar(raw_value, line.number)
|
|
224
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
225
|
+
raise _error(self.lines[index].number, "nested value needs a mapping key")
|
|
226
|
+
result[key] = value
|
|
227
|
+
return result, index
|
|
228
|
+
|
|
229
|
+
def _list(self, index: int, indent: int) -> Tuple[list[Any], int]:
|
|
230
|
+
result: list[Any] = []
|
|
231
|
+
while index < len(self.lines):
|
|
232
|
+
line = self.lines[index]
|
|
233
|
+
if line.indent < indent:
|
|
234
|
+
break
|
|
235
|
+
if line.indent > indent:
|
|
236
|
+
raise _error(line.number, "unexpected indentation")
|
|
237
|
+
if not (line.content == "-" or line.content.startswith("- ")):
|
|
238
|
+
break
|
|
239
|
+
rest = line.content[1:].strip()
|
|
240
|
+
index += 1
|
|
241
|
+
if not rest:
|
|
242
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
243
|
+
child_indent = self.lines[index].indent
|
|
244
|
+
value, index = self._block(index, child_indent)
|
|
245
|
+
else:
|
|
246
|
+
value = None
|
|
247
|
+
result.append(value)
|
|
248
|
+
continue
|
|
249
|
+
colon = _find_colon(rest)
|
|
250
|
+
if colon < 1:
|
|
251
|
+
value = _parse_scalar(rest, line.number)
|
|
252
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
253
|
+
raise _error(self.lines[index].number, "unexpected nested value")
|
|
254
|
+
result.append(value)
|
|
255
|
+
continue
|
|
256
|
+
key = rest[:colon].strip()
|
|
257
|
+
raw_value = rest[colon + 1 :].strip()
|
|
258
|
+
if not key or any(character in key for character in "\n\r"):
|
|
259
|
+
raise _error(line.number, "invalid mapping key")
|
|
260
|
+
item: dict[str, Any] = {}
|
|
261
|
+
if raw_value == "":
|
|
262
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
263
|
+
child_indent = self.lines[index].indent
|
|
264
|
+
value, index = self._block(index, child_indent)
|
|
265
|
+
else:
|
|
266
|
+
value = {}
|
|
267
|
+
else:
|
|
268
|
+
value = _parse_scalar(raw_value, line.number)
|
|
269
|
+
item[key] = value
|
|
270
|
+
if index < len(self.lines) and self.lines[index].indent > indent:
|
|
271
|
+
continuation_indent = self.lines[index].indent
|
|
272
|
+
continuation, index = self._mapping(index, continuation_indent)
|
|
273
|
+
for continuation_key, continuation_value in continuation.items():
|
|
274
|
+
if continuation_key in item:
|
|
275
|
+
raise _error(line.number, "duplicate key in list item")
|
|
276
|
+
item[continuation_key] = continuation_value
|
|
277
|
+
result.append(item)
|
|
278
|
+
return result, index
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _mapping_entry(line: _Line) -> Tuple[str, str]:
|
|
282
|
+
colon = _find_colon(line.content)
|
|
283
|
+
if colon < 1:
|
|
284
|
+
raise _error(line.number, "expected key: value")
|
|
285
|
+
key = line.content[:colon].strip()
|
|
286
|
+
if not key or key[0] in "-?!" or any(character in key for character in "\n\r"):
|
|
287
|
+
raise _error(line.number, "invalid mapping key")
|
|
288
|
+
if key.startswith(("'", '"')):
|
|
289
|
+
parsed_key = _parse_scalar(key, line.number)
|
|
290
|
+
if not isinstance(parsed_key, str):
|
|
291
|
+
raise _error(line.number, "mapping key must be a string")
|
|
292
|
+
key = parsed_key
|
|
293
|
+
return key, line.content[colon + 1 :].strip()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load_yaml(text: str) -> Any:
|
|
297
|
+
return _Parser(_prepare_lines(text)).parse()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _dump_scalar(value: Any) -> str:
|
|
301
|
+
if value is None:
|
|
302
|
+
return "null"
|
|
303
|
+
if value is True:
|
|
304
|
+
return "true"
|
|
305
|
+
if value is False:
|
|
306
|
+
return "false"
|
|
307
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
308
|
+
return str(value)
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
if "\n" in value or "\r" in value:
|
|
311
|
+
raise FrontmatterError("multiline strings are not supported")
|
|
312
|
+
return json.dumps(value, ensure_ascii=False)
|
|
313
|
+
raise FrontmatterError("unsupported value type")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_key(key: Any) -> str:
|
|
317
|
+
if not isinstance(key, str) or not key or "\n" in key or "\r" in key:
|
|
318
|
+
raise FrontmatterError("mapping keys must be non-empty single-line strings")
|
|
319
|
+
if any(character in key for character in "{}[]"):
|
|
320
|
+
raise FrontmatterError("mapping key uses unsupported syntax")
|
|
321
|
+
return key
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _is_scalar(value: Any) -> bool:
|
|
325
|
+
return value is None or isinstance(value, (str, int, float, bool))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _dump_mapping(mapping: Mapping[str, Any], indent: int) -> List[str]:
|
|
329
|
+
lines: List[str] = []
|
|
330
|
+
prefix = " " * indent
|
|
331
|
+
for raw_key, value in mapping.items():
|
|
332
|
+
key = _validate_key(raw_key)
|
|
333
|
+
if _is_scalar(value):
|
|
334
|
+
lines.append(f"{prefix}{key}: {_dump_scalar(value)}")
|
|
335
|
+
elif isinstance(value, list) and all(_is_scalar(item) for item in value):
|
|
336
|
+
items = ", ".join(_dump_scalar(item) for item in value)
|
|
337
|
+
lines.append(f"{prefix}{key}: [{items}]")
|
|
338
|
+
else:
|
|
339
|
+
lines.append(f"{prefix}{key}:")
|
|
340
|
+
lines.extend(_dump_value(value, indent + 2))
|
|
341
|
+
return lines
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _dump_value(value: Any, indent: int) -> List[str]:
|
|
345
|
+
prefix = " " * indent
|
|
346
|
+
if isinstance(value, Mapping):
|
|
347
|
+
return _dump_mapping(value, indent)
|
|
348
|
+
if isinstance(value, list):
|
|
349
|
+
lines: List[str] = []
|
|
350
|
+
for item in value:
|
|
351
|
+
if _is_scalar(item):
|
|
352
|
+
lines.append(f"{prefix}- {_dump_scalar(item)}")
|
|
353
|
+
elif isinstance(item, Mapping):
|
|
354
|
+
entries = list(item.items())
|
|
355
|
+
if not entries:
|
|
356
|
+
lines.append(f"{prefix}- {{}}")
|
|
357
|
+
continue
|
|
358
|
+
first_key, first_value = entries[0]
|
|
359
|
+
key = _validate_key(first_key)
|
|
360
|
+
if _is_scalar(first_value):
|
|
361
|
+
lines.append(f"{prefix}- {key}: {_dump_scalar(first_value)}")
|
|
362
|
+
else:
|
|
363
|
+
lines.append(f"{prefix}- {key}:")
|
|
364
|
+
lines.extend(_dump_value(first_value, indent + 4))
|
|
365
|
+
if len(entries) > 1:
|
|
366
|
+
lines.extend(_dump_mapping(dict(entries[1:]), indent + 2))
|
|
367
|
+
else:
|
|
368
|
+
raise FrontmatterError("unsupported list item type")
|
|
369
|
+
return lines
|
|
370
|
+
raise FrontmatterError("unsupported nested value type")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def dump_yaml(value: Mapping[str, Any]) -> str:
|
|
374
|
+
if not isinstance(value, Mapping):
|
|
375
|
+
raise FrontmatterError("top-level YAML value must be a mapping")
|
|
376
|
+
return "\n".join(_dump_mapping(value, 0)) + "\n"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_frontmatter(text: str) -> Tuple[dict[str, Any], str]:
|
|
380
|
+
lines = text.splitlines()
|
|
381
|
+
if not lines or lines[0].strip() != "---":
|
|
382
|
+
raise FrontmatterError("Markdown memory must start with frontmatter")
|
|
383
|
+
closing = None
|
|
384
|
+
for index in range(1, len(lines)):
|
|
385
|
+
if lines[index].strip() == "---":
|
|
386
|
+
closing = index
|
|
387
|
+
break
|
|
388
|
+
if closing is None:
|
|
389
|
+
raise FrontmatterError("frontmatter closing marker is missing")
|
|
390
|
+
metadata = load_yaml("\n".join(lines[1:closing]))
|
|
391
|
+
if not isinstance(metadata, dict):
|
|
392
|
+
raise FrontmatterError("frontmatter must be a mapping")
|
|
393
|
+
body = "\n".join(lines[closing + 1 :])
|
|
394
|
+
if body.startswith("\n"):
|
|
395
|
+
body = body[1:]
|
|
396
|
+
return metadata, body.rstrip("\n")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def dump_frontmatter(metadata: Mapping[str, Any], body: str) -> str:
|
|
400
|
+
if not isinstance(body, str):
|
|
401
|
+
raise FrontmatterError("memory body must be text")
|
|
402
|
+
rendered = dump_yaml(metadata)
|
|
403
|
+
return f"---\n{rendered}---\n\n{body.rstrip(chr(10))}\n"
|