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