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/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"