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/budget.py ADDED
@@ -0,0 +1,123 @@
1
+ """Small, deterministic budgets for automatic memory context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+ from .models import DirectoryEntry, Memory
10
+
11
+
12
+ # Automatic recall is deliberately a directory, not an implicit full-memory
13
+ # read. Keep these values importable by host adapters so they cannot drift.
14
+ MAX_CONTEXT_ITEMS = 3
15
+ MAX_CONTEXT_CHARS = 600
16
+ MAX_DIRECTORY_TITLE_CHARS = 96
17
+ MAX_DIRECTORY_SCOPE_CHARS = 64
18
+ MAX_DIRECTORY_SCOPES = 4
19
+ # Protocol responses use wider budgets than automatic context injection. The
20
+ # limits bound metadata only; memory bodies are available through read_page.
21
+ MAX_SCOPE_CATALOG_ITEMS = 20
22
+ MAX_SCOPE_CATALOG_CHARS = 2000
23
+ MAX_SEARCH_CANDIDATE_ITEMS = 20
24
+ MAX_SEARCH_CANDIDATE_CHARS = 4000
25
+
26
+
27
+ def context_limit(limit: int | None) -> int:
28
+ """Return the requested context limit clamped to the automatic safety cap."""
29
+
30
+ if limit is None:
31
+ return MAX_CONTEXT_ITEMS
32
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
33
+ raise ValueError("limit must be a non-negative integer")
34
+ return min(limit, MAX_CONTEXT_ITEMS)
35
+
36
+
37
+ def payload_chars(value: Any) -> int:
38
+ """Measure the compact JSON payload size without changing its contents."""
39
+
40
+ return len(
41
+ json.dumps(
42
+ value,
43
+ ensure_ascii=False,
44
+ allow_nan=False,
45
+ separators=(",", ":"),
46
+ )
47
+ )
48
+
49
+
50
+ def _display_text(value: str, maximum: int) -> str:
51
+ """Make a bounded, single-line display value without changing storage."""
52
+
53
+ value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
54
+ value = value.replace("\t", " ")
55
+ if len(value) <= maximum:
56
+ return value
57
+ if maximum <= 1:
58
+ return value[:maximum]
59
+ return value[: maximum - 1] + "…"
60
+
61
+
62
+ def directory_entry(memory: Memory | DirectoryEntry) -> DirectoryEntry:
63
+ """Build the bounded, body-free representation used for recall."""
64
+
65
+ if not isinstance(memory, (Memory, DirectoryEntry)):
66
+ raise TypeError("directory item must be a Memory or DirectoryEntry")
67
+ scopes = memory.scopes
68
+ display_scopes = [
69
+ _display_text(scope, MAX_DIRECTORY_SCOPE_CHARS)
70
+ for scope in list(scopes)[:MAX_DIRECTORY_SCOPES]
71
+ ]
72
+ if len(scopes) > MAX_DIRECTORY_SCOPES:
73
+ display_scopes.append("…")
74
+ return DirectoryEntry(
75
+ memory_id=memory.memory_id,
76
+ title=_display_text(memory.title, MAX_DIRECTORY_TITLE_CHARS),
77
+ scopes=display_scopes,
78
+ )
79
+
80
+
81
+ def fit_directory_items(
82
+ items: Iterable[DirectoryEntry | Memory],
83
+ *,
84
+ limit: int | None = None,
85
+ ) -> list[DirectoryEntry]:
86
+ """Select directory entries under the count and 600-character budgets."""
87
+
88
+ max_items = context_limit(limit)
89
+ if max_items == 0:
90
+ return []
91
+ selected: list[DirectoryEntry] = []
92
+ used = 2 # The surrounding JSON array brackets.
93
+ for item in items:
94
+ if len(selected) >= max_items:
95
+ break
96
+ try:
97
+ entry = directory_entry(item)
98
+ size = payload_chars(entry.to_dict())
99
+ except (TypeError, ValueError, OverflowError):
100
+ continue
101
+ additional = size + (1 if selected else 0)
102
+ if used + additional > MAX_CONTEXT_CHARS:
103
+ continue
104
+ selected.append(entry)
105
+ used += additional
106
+ return selected
107
+
108
+
109
+ __all__ = [
110
+ "MAX_CONTEXT_CHARS",
111
+ "MAX_CONTEXT_ITEMS",
112
+ "MAX_DIRECTORY_SCOPE_CHARS",
113
+ "MAX_DIRECTORY_SCOPES",
114
+ "MAX_DIRECTORY_TITLE_CHARS",
115
+ "MAX_SCOPE_CATALOG_CHARS",
116
+ "MAX_SCOPE_CATALOG_ITEMS",
117
+ "MAX_SEARCH_CANDIDATE_CHARS",
118
+ "MAX_SEARCH_CANDIDATE_ITEMS",
119
+ "context_limit",
120
+ "directory_entry",
121
+ "fit_directory_items",
122
+ "payload_chars",
123
+ ]
memleaf/capture.py ADDED
@@ -0,0 +1,311 @@
1
+ """Append-only visible-event capture with event-level idempotency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .index import (
12
+ EVENT_CONTENT,
13
+ EVENT_END,
14
+ EVENT_START,
15
+ event_key,
16
+ escape_event_markers,
17
+ extract_event_keys,
18
+ extract_event_metadata,
19
+ turn_key,
20
+ )
21
+ from .locking import atomic_write_json, atomic_write_text, read_json
22
+ from .models import CaptureResult
23
+ from .redaction import redact_text
24
+ from .vault import Vault, safe_component
25
+
26
+
27
+ def _timestamp() -> str:
28
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
29
+
30
+
31
+ def _event_id(source: str, session_id: str, turn_id: str, role: str, event_id: Optional[str]) -> str:
32
+ if event_id is not None:
33
+ if not isinstance(event_id, str) or not event_id or "\x00" in event_id or "\n" in event_id or "\r" in event_id:
34
+ raise ValueError("invalid event id")
35
+ return event_id
36
+ return f"{source}/{session_id}/{turn_id}/{role}"
37
+
38
+
39
+ def _empty_processed() -> dict:
40
+ return {"version": 1, "event_keys": [], "events": {}, "sessions": {}}
41
+
42
+
43
+ def _read_processed(path: Path) -> dict:
44
+ if not path.exists() or path.is_symlink():
45
+ return _empty_processed()
46
+ try:
47
+ value = read_json(path)
48
+ except (OSError, ValueError, TypeError):
49
+ return _empty_processed()
50
+ if not isinstance(value, dict):
51
+ return _empty_processed()
52
+ value.setdefault("version", 1)
53
+ value.setdefault("event_keys", [])
54
+ value.setdefault("events", {})
55
+ value.setdefault("sessions", {})
56
+ return value
57
+
58
+
59
+ def _known_event_keys(vault: Vault, processed: dict) -> set[str]:
60
+ known: set[str] = set()
61
+ event_keys = processed.get("event_keys", [])
62
+ if isinstance(event_keys, list):
63
+ known.update(
64
+ item.casefold()
65
+ for item in event_keys
66
+ if isinstance(item, str) and len(item) == 64
67
+ )
68
+ events = processed.get("events", {})
69
+ if isinstance(events, dict):
70
+ known.update(key.casefold() for key in events if isinstance(key, str) and len(key) == 64)
71
+ for path in vault.list_markdown("inbox"):
72
+ try:
73
+ text = path.read_text(encoding="utf-8")
74
+ except (OSError, UnicodeError):
75
+ continue
76
+ known.update(extract_event_keys(text))
77
+ return known
78
+
79
+
80
+ def _new_session_text(source: str, session_id: str, timestamp: str) -> str:
81
+ return (
82
+ f"# Session {source}/{session_id}\n"
83
+ f"- source: {source}\n"
84
+ f"- session_id: {session_id}\n"
85
+ f"- started: {timestamp}\n"
86
+ f"- updated: {timestamp}\n\n"
87
+ )
88
+
89
+
90
+ def _session_key(source: str, session_id: str) -> str:
91
+ return f"{source}/{session_id}"
92
+
93
+
94
+ def _safe_turn_id(turn_id: str) -> str:
95
+ # A turn id is useful to the parser but can contain a password/token label
96
+ # when supplied by a host. Keep its stable redacted representation only.
97
+ return redact_text(turn_id)
98
+
99
+
100
+ def _turn_index(
101
+ existing: str,
102
+ processed: dict,
103
+ session_key: str,
104
+ raw_turn_id: str,
105
+ persisted_turn_id: str,
106
+ ) -> tuple[int, dict]:
107
+ stable_turn_key = turn_key(raw_turn_id)
108
+ sessions = processed.get("sessions")
109
+ if not isinstance(sessions, dict):
110
+ sessions = {}
111
+ state = sessions.get(session_key)
112
+ if not isinstance(state, dict):
113
+ state = {}
114
+ turns = state.get("turns")
115
+ if not isinstance(turns, dict):
116
+ turns = {}
117
+ for candidate_key in (stable_turn_key, persisted_turn_id):
118
+ if candidate_key in turns and isinstance(turns[candidate_key], int) and not isinstance(turns[candidate_key], bool):
119
+ return turns[candidate_key], state
120
+
121
+ maximum = 0
122
+ for metadata in extract_event_metadata(existing):
123
+ index = metadata.get("turn_index")
124
+ if isinstance(index, int) and not isinstance(index, bool):
125
+ maximum = max(maximum, index)
126
+ if (
127
+ metadata.get("turn_key") == stable_turn_key
128
+ or (not metadata.get("turn_key") and metadata.get("turn_id") == persisted_turn_id)
129
+ ) and isinstance(index, int):
130
+ turns[stable_turn_key] = index
131
+ state["turns"] = turns
132
+ return index, state
133
+ next_index = state.get("next_turn_index", maximum + 1)
134
+ if not isinstance(next_index, int) or isinstance(next_index, bool) or next_index < 1:
135
+ next_index = maximum + 1
136
+ turns[stable_turn_key] = next_index
137
+ state["turns"] = turns
138
+ state["next_turn_index"] = next_index + 1
139
+ return next_index, state
140
+
141
+
142
+ def _append_event(
143
+ existing: str,
144
+ source: str,
145
+ session_id: str,
146
+ turn_id: str,
147
+ role: str,
148
+ content: str,
149
+ event_digest: str,
150
+ event_turn_key: str,
151
+ turn_index: int,
152
+ ) -> str:
153
+ timestamp = _timestamp()
154
+ if not existing:
155
+ existing = _new_session_text(source, session_id, timestamp)
156
+ updated = re.sub(r"(?m)^- updated:.*$", f"- updated: {timestamp}", existing, count=1)
157
+ if updated == existing and not existing.endswith("\n"):
158
+ updated += "\n"
159
+ if not updated.endswith("\n\n"):
160
+ updated = updated.rstrip("\n") + "\n\n"
161
+ metadata = {
162
+ "event_key": event_digest,
163
+ "role": role,
164
+ "session_id": session_id,
165
+ "source": source,
166
+ "turn_id": turn_id,
167
+ "turn_key": event_turn_key,
168
+ "turn_index": turn_index,
169
+ "timestamp": timestamp,
170
+ }
171
+ metadata_line = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
172
+ return (
173
+ f"{updated}{EVENT_START}\n"
174
+ f"{metadata_line}\n"
175
+ f"{EVENT_CONTENT}\n"
176
+ f"{content.rstrip(chr(10))}\n"
177
+ f"{EVENT_END}\n"
178
+ # Keep the digest-only v1 marker for older local readers. The body
179
+ # is escaped before this point, so only this writer can emit a live
180
+ # legacy marker.
181
+ f"<!-- memleaf:event-key:v1:{event_digest} -->\n"
182
+ )
183
+
184
+
185
+ def _safe_event_entry(
186
+ *, source: str, session_id: str, turn_id: str, role: str, turn_index: int, captured_at: str
187
+ ) -> dict:
188
+ return {
189
+ "event_key": "",
190
+ "source": source,
191
+ "session_id": session_id,
192
+ "turn_id": turn_id,
193
+ "role": role,
194
+ "turn_index": turn_index,
195
+ "captured_at": captured_at,
196
+ }
197
+
198
+
199
+ def capture_event(
200
+ vault: Vault,
201
+ *,
202
+ source: str,
203
+ session_id: str,
204
+ turn_id: str,
205
+ role: str,
206
+ content: str,
207
+ event_id: Optional[str] = None,
208
+ record: bool = True,
209
+ visible: bool = True,
210
+ ) -> CaptureResult:
211
+ """Capture one visible event; all persisted text is redacted first."""
212
+
213
+ source = safe_component(source, "source")
214
+ session_id = safe_component(session_id, "session id")
215
+ if not isinstance(turn_id, str) or not turn_id or "\x00" in turn_id or "\n" in turn_id or "\r" in turn_id:
216
+ raise ValueError("invalid turn id")
217
+ role = safe_component(str(role), "role")
218
+ if not isinstance(content, str):
219
+ raise TypeError("captured content must be text")
220
+ resolved_event_id = _event_id(source, session_id, turn_id, role, event_id)
221
+ resolved_event_key = event_key(resolved_event_id)
222
+ resolved_turn_key = turn_key(turn_id)
223
+ persisted_turn_id = _safe_turn_id(turn_id)
224
+ safe_content = escape_event_markers(redact_text(content))
225
+ if not record or not visible or role not in ("user", "assistant"):
226
+ return CaptureResult(resolved_event_id, stored=False, duplicate=False, content=safe_content)
227
+
228
+ with vault.lock():
229
+ processed = _read_processed(vault.processed_index_path)
230
+ known_keys = _known_event_keys(vault, processed)
231
+ if resolved_event_key in known_keys:
232
+ path = vault.session_path(source, session_id)
233
+ return CaptureResult(
234
+ resolved_event_id,
235
+ stored=False,
236
+ duplicate=True,
237
+ path=path if path.exists() else None,
238
+ content=safe_content,
239
+ )
240
+
241
+ path = vault.session_path(source, session_id)
242
+ if path.is_symlink():
243
+ raise ValueError("unsafe inbox session path")
244
+ try:
245
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
246
+ except (OSError, UnicodeError) as error:
247
+ raise ValueError("cannot read inbox session") from error
248
+ if resolved_event_key in set(extract_event_keys(existing)):
249
+ return CaptureResult(resolved_event_id, stored=False, duplicate=True, path=path, content=safe_content)
250
+
251
+ session_key = _session_key(source, session_id)
252
+ turn_index, state = _turn_index(
253
+ existing,
254
+ processed,
255
+ session_key,
256
+ turn_id,
257
+ persisted_turn_id,
258
+ )
259
+ updated = _append_event(
260
+ existing,
261
+ source,
262
+ session_id,
263
+ persisted_turn_id,
264
+ role,
265
+ safe_content,
266
+ resolved_event_key,
267
+ resolved_turn_key,
268
+ turn_index,
269
+ )
270
+ atomic_write_text(path, updated)
271
+
272
+ event_keys = _known_event_keys(vault, processed)
273
+ event_keys.add(resolved_event_key)
274
+ existing_events = processed.get("events", {})
275
+ event_entries = {
276
+ key: dict(value)
277
+ for key, value in existing_events.items()
278
+ if isinstance(key, str) and len(key) == 64 and isinstance(value, dict)
279
+ } if isinstance(existing_events, dict) else {}
280
+ captured_at = _timestamp()
281
+ entry = _safe_event_entry(
282
+ source=source,
283
+ session_id=session_id,
284
+ turn_id=persisted_turn_id,
285
+ role=role,
286
+ turn_index=turn_index,
287
+ captured_at=captured_at,
288
+ )
289
+ entry["turn_key"] = resolved_turn_key
290
+ entry["event_key"] = resolved_event_key
291
+ event_entries[resolved_event_key] = entry
292
+ sessions = processed.get("sessions", {})
293
+ if not isinstance(sessions, dict):
294
+ sessions = {}
295
+ # Merge only capture-owned fields. In particular, do not replace a
296
+ # processer's in-flight ``processing`` state during concurrent capture.
297
+ new_state = dict(state)
298
+ old_state = sessions.get(session_key)
299
+ if isinstance(old_state, dict) and "processing" in old_state:
300
+ new_state["processing"] = old_state["processing"]
301
+ sessions[session_key] = new_state
302
+ atomic_write_json(
303
+ vault.processed_index_path,
304
+ {
305
+ "version": 1,
306
+ "event_keys": sorted(event_keys),
307
+ "events": event_entries,
308
+ "sessions": sessions,
309
+ },
310
+ )
311
+ return CaptureResult(resolved_event_id, stored=True, duplicate=False, path=path, content=safe_content)