memoryledger 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.
- memoryledger/__init__.py +8 -0
- memoryledger/_version.py +24 -0
- memoryledger/adopt.py +173 -0
- memoryledger/cli.py +700 -0
- memoryledger/errors.py +18 -0
- memoryledger/evidence_scan.py +218 -0
- memoryledger/guardrails.py +157 -0
- memoryledger/intake.py +111 -0
- memoryledger/launcher.py +7 -0
- memoryledger/models.py +172 -0
- memoryledger/py.typed +0 -0
- memoryledger/render.py +313 -0
- memoryledger/review.py +19 -0
- memoryledger/run_import.py +136 -0
- memoryledger/storage.py +463 -0
- memoryledger/templates.py +161 -0
- memoryledger-0.1.0.dist-info/METADATA +67 -0
- memoryledger-0.1.0.dist-info/RECORD +24 -0
- memoryledger-0.1.0.dist-info/WHEEL +5 -0
- memoryledger-0.1.0.dist-info/entry_points.txt +3 -0
- memoryledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- memoryledger-0.1.0.dist-info/scm_file_list.json +147 -0
- memoryledger-0.1.0.dist-info/scm_version.json +8 -0
- memoryledger-0.1.0.dist-info/top_level.txt +1 -0
memoryledger/render.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ledgercore.atomic import atomic_write_text
|
|
8
|
+
from ledgercore.time import utc_now_iso
|
|
9
|
+
|
|
10
|
+
from .guardrails import safe_to_replace, validate_generated_text, validate_scope_path
|
|
11
|
+
from .models import GENERATED_MARKER, Config, Memory
|
|
12
|
+
from .storage import Store
|
|
13
|
+
|
|
14
|
+
DOC_MAP = {
|
|
15
|
+
"procedure": ("Procedures", "procedures.md"),
|
|
16
|
+
"semantic": ("Semantic memory", "semantic-memory.md"),
|
|
17
|
+
"episode": ("Episodes", "episodes.md"),
|
|
18
|
+
"document": ("Documents", "documents.md"),
|
|
19
|
+
}
|
|
20
|
+
ROOT_SECTIONS = {
|
|
21
|
+
"rule": "Rules",
|
|
22
|
+
"procedure": "Procedures",
|
|
23
|
+
"semantic": "Semantic memory",
|
|
24
|
+
"learning": "Learnings",
|
|
25
|
+
"episode": "Episodes",
|
|
26
|
+
"document": "Documents",
|
|
27
|
+
}
|
|
28
|
+
LONG_THRESHOLD = 500
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RenderResult:
|
|
33
|
+
root_text: str
|
|
34
|
+
linked_docs: dict[str, str]
|
|
35
|
+
nested_docs: dict[str, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _escape_markdown(value: str) -> str:
|
|
39
|
+
escaped = value.replace("\\", "\\\\")
|
|
40
|
+
for character in ("`", "[", "]", "(", ")", "*", "_"):
|
|
41
|
+
escaped = escaped.replace(character, f"\\{character}")
|
|
42
|
+
return escaped
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _active(config: Config, memories: list[Memory]) -> list[Memory]:
|
|
46
|
+
result = []
|
|
47
|
+
for memory in memories:
|
|
48
|
+
if memory.status != "accepted" and not config.render.include_rejected:
|
|
49
|
+
continue
|
|
50
|
+
if memory.kind == "local" and not config.render.include_local:
|
|
51
|
+
continue
|
|
52
|
+
result.append(memory)
|
|
53
|
+
order = {kind: i for i, kind in enumerate(config.render.sort_order)}
|
|
54
|
+
return sorted(result, key=lambda m: (order.get(m.kind, 99), m.priority, m.id))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bullet(title: str, content: str) -> str:
|
|
58
|
+
one = " ".join(
|
|
59
|
+
line.strip() for line in content.strip().splitlines() if line.strip()
|
|
60
|
+
)
|
|
61
|
+
if one.startswith("-") or one.startswith("1."):
|
|
62
|
+
return content.strip()
|
|
63
|
+
return f"- {one}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _doc_path(config: Config, kind: str) -> str:
|
|
67
|
+
return f"{config.render.linked_docs_dir}/{DOC_MAP[kind][1]}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _evidence_comment(config: Config, memory: Memory) -> str:
|
|
71
|
+
if not config.render.include_evidence or not memory.evidence_refs:
|
|
72
|
+
return ""
|
|
73
|
+
if config.render.evidence_index_path:
|
|
74
|
+
return (
|
|
75
|
+
f"\n<!-- memoryledger:evidence {memory.id} "
|
|
76
|
+
f"{config.render.evidence_index_path}#{memory.id} -->"
|
|
77
|
+
)
|
|
78
|
+
return (
|
|
79
|
+
f"\n<!-- memoryledger:evidence {memory.id} "
|
|
80
|
+
+ ",".join(ref.kind for ref in memory.evidence_refs)
|
|
81
|
+
+ " -->"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_evidence_ref(ref) -> str:
|
|
86
|
+
label = _escape_markdown(ref.title)
|
|
87
|
+
if ref.line_start is not None and ref.line_end is not None:
|
|
88
|
+
if ref.line_start == ref.line_end:
|
|
89
|
+
label += f" (line {ref.line_start})"
|
|
90
|
+
else:
|
|
91
|
+
label += f" (lines {ref.line_start}-{ref.line_end})"
|
|
92
|
+
return f"- {label} (`{_escape_markdown(ref.uri)}`)"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_all(config: Config) -> RenderResult:
|
|
96
|
+
store = Store(config)
|
|
97
|
+
memories = _active(config, store.all_memories())
|
|
98
|
+
linked: dict[str, list[tuple[Memory, str]]] = {}
|
|
99
|
+
root: dict[str, list[tuple[Memory, str]]] = {k: [] for k in ROOT_SECTIONS}
|
|
100
|
+
nested: dict[str, list[tuple[Memory, str]]] = {}
|
|
101
|
+
|
|
102
|
+
for memory in memories:
|
|
103
|
+
content = store.read_content(memory.id)
|
|
104
|
+
if memory.render_target == "nested_agents":
|
|
105
|
+
nested.setdefault(memory.scope_path, []).append((memory, content))
|
|
106
|
+
continue
|
|
107
|
+
use_link = memory.render_target == "linked_doc" or (
|
|
108
|
+
config.render.linked_docs_enabled
|
|
109
|
+
and memory.kind in DOC_MAP
|
|
110
|
+
and len(content) > LONG_THRESHOLD
|
|
111
|
+
)
|
|
112
|
+
if use_link and memory.kind in DOC_MAP:
|
|
113
|
+
linked.setdefault(memory.kind, []).append((memory, content))
|
|
114
|
+
elif memory.kind in root:
|
|
115
|
+
evidence_comment = _evidence_comment(config, memory)
|
|
116
|
+
if memory.kind == "document":
|
|
117
|
+
root[memory.kind].append(
|
|
118
|
+
(memory, f"- {memory.title}{evidence_comment}")
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
root[memory.kind].append(
|
|
122
|
+
(memory, _bullet(memory.title, content) + evidence_comment)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
linked_docs = _render_linked(config, linked)
|
|
126
|
+
if config.render.include_evidence and config.render.evidence_index_path:
|
|
127
|
+
index_lines = [
|
|
128
|
+
"# Evidence index",
|
|
129
|
+
"",
|
|
130
|
+
GENERATED_MARKER,
|
|
131
|
+
"<!-- Source: .memoryledger; regenerate with `memoryledger render` and `memoryledger export`. -->",
|
|
132
|
+
"",
|
|
133
|
+
]
|
|
134
|
+
for memory in memories:
|
|
135
|
+
if not memory.evidence_refs:
|
|
136
|
+
continue
|
|
137
|
+
index_lines += [
|
|
138
|
+
f'<a id="{memory.id}"></a>',
|
|
139
|
+
"",
|
|
140
|
+
f"## {memory.id} — {memory.title}",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
for ref in memory.evidence_refs:
|
|
144
|
+
index_lines.append(_format_evidence_ref(ref))
|
|
145
|
+
index_lines.append("")
|
|
146
|
+
linked_docs[config.render.evidence_index_path] = (
|
|
147
|
+
"\n".join(index_lines).rstrip() + "\n"
|
|
148
|
+
)
|
|
149
|
+
nested_docs = _render_nested(config, nested)
|
|
150
|
+
root_text = _render_root(config, root, linked_docs, nested_docs)
|
|
151
|
+
validate_generated_text(root_text, config.render.max_root_agents_chars)
|
|
152
|
+
for text in linked_docs.values():
|
|
153
|
+
validate_generated_text(text, config.render.max_linked_doc_chars)
|
|
154
|
+
return RenderResult(root_text, linked_docs, nested_docs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _render_root(
|
|
158
|
+
config: Config,
|
|
159
|
+
sections: dict[str, list[tuple[Memory, str]]],
|
|
160
|
+
linked_docs: dict[str, str],
|
|
161
|
+
nested_docs: dict[str, str],
|
|
162
|
+
) -> str:
|
|
163
|
+
lines = [
|
|
164
|
+
"# AGENTS.md",
|
|
165
|
+
"",
|
|
166
|
+
GENERATED_MARKER,
|
|
167
|
+
"<!-- Source: .memoryledger; regenerate with `memoryledger render` and `memoryledger export`. -->",
|
|
168
|
+
"",
|
|
169
|
+
"## Project memory policy",
|
|
170
|
+
"",
|
|
171
|
+
"This file contains reviewed long-term project memory.",
|
|
172
|
+
"",
|
|
173
|
+
]
|
|
174
|
+
for kind in config.render.sort_order:
|
|
175
|
+
if kind not in ROOT_SECTIONS or not sections.get(kind):
|
|
176
|
+
continue
|
|
177
|
+
items = sections[kind]
|
|
178
|
+
if any(memory.section for memory, _text in items):
|
|
179
|
+
lines += [f"## {ROOT_SECTIONS[kind]}", ""]
|
|
180
|
+
grouped: dict[str, list[str]] = {}
|
|
181
|
+
for memory, text in items:
|
|
182
|
+
grouped.setdefault(memory.section, []).append(text)
|
|
183
|
+
if grouped.get(""):
|
|
184
|
+
lines += [*grouped.pop(""), ""]
|
|
185
|
+
for section in sorted(grouped, key=str.casefold):
|
|
186
|
+
lines += [f"### {section}", "", *grouped[section], ""]
|
|
187
|
+
else:
|
|
188
|
+
lines += [f"## {ROOT_SECTIONS[kind]}", "", *(t for _m, t in items), ""]
|
|
189
|
+
if linked_docs:
|
|
190
|
+
lines += ["## Linked documents", ""]
|
|
191
|
+
for path in sorted(linked_docs):
|
|
192
|
+
title = Path(path).stem.replace("-", " ").capitalize()
|
|
193
|
+
lines.append(f"- [{title}]({path})")
|
|
194
|
+
lines.append("")
|
|
195
|
+
if nested_docs:
|
|
196
|
+
lines += ["## Nested agent files", ""]
|
|
197
|
+
for path in sorted(nested_docs):
|
|
198
|
+
lines.append(f"- [{path}]({path})")
|
|
199
|
+
lines.append("")
|
|
200
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _render_linked(
|
|
204
|
+
config: Config, linked: dict[str, list[tuple[Memory, str]]]
|
|
205
|
+
) -> dict[str, str]:
|
|
206
|
+
docs: dict[str, str] = {}
|
|
207
|
+
for kind, items in linked.items():
|
|
208
|
+
title, _filename = DOC_MAP[kind]
|
|
209
|
+
lines = [
|
|
210
|
+
f"# {title}",
|
|
211
|
+
"",
|
|
212
|
+
GENERATED_MARKER,
|
|
213
|
+
"<!-- Source: .memoryledger; regenerate with `memoryledger render` and `memoryledger export`. -->",
|
|
214
|
+
"",
|
|
215
|
+
]
|
|
216
|
+
for memory, content in sorted(
|
|
217
|
+
items, key=lambda item: (item[0].priority, item[0].id)
|
|
218
|
+
):
|
|
219
|
+
lines += [f"## {memory.title}", "", content.strip(), ""]
|
|
220
|
+
if config.render.include_evidence and memory.evidence_refs:
|
|
221
|
+
lines += ["### Evidence", ""]
|
|
222
|
+
for ref in memory.evidence_refs:
|
|
223
|
+
lines.append(_format_evidence_ref(ref))
|
|
224
|
+
lines.append("")
|
|
225
|
+
docs[_doc_path(config, kind)] = "\n".join(lines).rstrip() + "\n"
|
|
226
|
+
return docs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _render_nested(
|
|
230
|
+
config: Config, nested: dict[str, list[tuple[Memory, str]]]
|
|
231
|
+
) -> dict[str, str]:
|
|
232
|
+
docs: dict[str, str] = {}
|
|
233
|
+
for scope_path, items in nested.items():
|
|
234
|
+
validate_scope_path(config.root, scope_path)
|
|
235
|
+
lines = [
|
|
236
|
+
"# AGENTS.md",
|
|
237
|
+
"",
|
|
238
|
+
GENERATED_MARKER,
|
|
239
|
+
"<!-- Source: .memoryledger; regenerate with `memoryledger render` and `memoryledger export`. -->",
|
|
240
|
+
"",
|
|
241
|
+
"## Directory memory policy",
|
|
242
|
+
"",
|
|
243
|
+
f"This file applies to `{scope_path}` and its subtree.",
|
|
244
|
+
"",
|
|
245
|
+
]
|
|
246
|
+
by_kind: dict[str, list[tuple[Memory, str]]] = {}
|
|
247
|
+
for memory, content in items:
|
|
248
|
+
by_kind.setdefault(memory.kind, []).append((memory, content))
|
|
249
|
+
for kind in config.render.sort_order:
|
|
250
|
+
if kind not in by_kind:
|
|
251
|
+
continue
|
|
252
|
+
lines += [f"## {ROOT_SECTIONS.get(kind, kind.title())}", ""]
|
|
253
|
+
for memory, content in sorted(
|
|
254
|
+
by_kind[kind], key=lambda item: (item[0].priority, item[0].id)
|
|
255
|
+
):
|
|
256
|
+
lines.append(_bullet(memory.title, content))
|
|
257
|
+
lines.append("")
|
|
258
|
+
docs[f"{scope_path}/AGENTS.md"] = "\n".join(lines).rstrip() + "\n"
|
|
259
|
+
return docs
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def write_rendered(
|
|
263
|
+
config: Config, result: RenderResult, out: Path | None = None
|
|
264
|
+
) -> list[Path]:
|
|
265
|
+
written: list[Path] = []
|
|
266
|
+
root_path = out or config.storage_dir / "rendered" / "AGENTS.md"
|
|
267
|
+
root_path.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
atomic_write_text(root_path, result.root_text)
|
|
269
|
+
written.append(root_path)
|
|
270
|
+
base = config.storage_dir / "rendered"
|
|
271
|
+
for rel, text in result.linked_docs.items():
|
|
272
|
+
path = base / rel
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
atomic_write_text(path, text)
|
|
275
|
+
written.append(path)
|
|
276
|
+
for rel, text in result.nested_docs.items():
|
|
277
|
+
path = base / "nested" / rel
|
|
278
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
atomic_write_text(path, text)
|
|
280
|
+
written.append(path)
|
|
281
|
+
return written
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def export(
|
|
285
|
+
config: Config,
|
|
286
|
+
result: RenderResult,
|
|
287
|
+
out: Path | None = None,
|
|
288
|
+
backup: bool = False,
|
|
289
|
+
include_nested: bool = False,
|
|
290
|
+
) -> list[Path]:
|
|
291
|
+
targets: list[tuple[Path, str]] = [
|
|
292
|
+
(out or config.root / config.render.root_agents_path, result.root_text)
|
|
293
|
+
]
|
|
294
|
+
targets += [(config.root / rel, text) for rel, text in result.linked_docs.items()]
|
|
295
|
+
if include_nested or config.render.nested_agents_enabled:
|
|
296
|
+
targets += [
|
|
297
|
+
(config.root / rel, text) for rel, text in result.nested_docs.items()
|
|
298
|
+
]
|
|
299
|
+
written = []
|
|
300
|
+
for path, text in targets:
|
|
301
|
+
safe_to_replace(path)
|
|
302
|
+
for path, text in targets:
|
|
303
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
if backup and path.exists():
|
|
305
|
+
shutil.copy2(
|
|
306
|
+
path,
|
|
307
|
+
path.with_suffix(
|
|
308
|
+
path.suffix + f".{utc_now_iso().replace(':', '')}.bak"
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
atomic_write_text(path, text)
|
|
312
|
+
written.append(path)
|
|
313
|
+
return written
|
memoryledger/review.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .errors import MemoryledgerError
|
|
4
|
+
from .guardrails import validate_memory
|
|
5
|
+
from .models import STATUSES, Memory
|
|
6
|
+
from .storage import Store
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def transition(store: Store, memory_id: str, status: str, reason: str) -> Memory:
|
|
10
|
+
if status not in STATUSES:
|
|
11
|
+
raise MemoryledgerError("INVALID_STATUS", f"Invalid status: {status}")
|
|
12
|
+
if not reason.strip():
|
|
13
|
+
raise MemoryledgerError("MISSING_REASON", "review transition requires a reason")
|
|
14
|
+
memory = store.get(memory_id)
|
|
15
|
+
content = store.read_content(memory_id)
|
|
16
|
+
evidence = store.read_evidence(memory_id) + f"\nReview reason: {reason}\n"
|
|
17
|
+
if status == "accepted":
|
|
18
|
+
validate_memory(memory, content, evidence, store.config.root)
|
|
19
|
+
return store.update_status(memory_id, status, reason)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .errors import MemoryledgerError
|
|
9
|
+
|
|
10
|
+
MAX_HTML_BYTES = 2_000_000
|
|
11
|
+
MAX_PAYLOAD_BYTES = 1_000_000
|
|
12
|
+
MAX_ENTRY_CHARS = 4000
|
|
13
|
+
SESSION_RE = re.compile(
|
|
14
|
+
r'<script[^>]+id=["\']session-data["\'][^>]*>([^<]+)</script>',
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
ALLOWED_TYPES = {"user_message", "memory_correction", "command_outcome"}
|
|
18
|
+
USER_PATTERNS = (
|
|
19
|
+
"remember this",
|
|
20
|
+
"store this",
|
|
21
|
+
"durable memory",
|
|
22
|
+
"add this to agents.md",
|
|
23
|
+
"build an agents.md",
|
|
24
|
+
"update agents.md",
|
|
25
|
+
)
|
|
26
|
+
WORKFLOW_KEYWORDS = (
|
|
27
|
+
"memoryledger",
|
|
28
|
+
"agents.md",
|
|
29
|
+
"render",
|
|
30
|
+
"export",
|
|
31
|
+
"review accept",
|
|
32
|
+
"memory create",
|
|
33
|
+
"manual_file",
|
|
34
|
+
"manual.bak",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RunProposal:
|
|
40
|
+
entry_id: str
|
|
41
|
+
entry_type: str
|
|
42
|
+
text: str
|
|
43
|
+
kind: str
|
|
44
|
+
title: str
|
|
45
|
+
render_target: str = "linked_doc"
|
|
46
|
+
timestamp: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalized(text: str) -> str:
|
|
50
|
+
return " ".join(text.lower().split())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _classify(entry_type: str, text: str) -> RunProposal | None:
|
|
54
|
+
normalized = _normalized(text)
|
|
55
|
+
if entry_type == "memory_correction":
|
|
56
|
+
if any(keyword in normalized for keyword in WORKFLOW_KEYWORDS):
|
|
57
|
+
return RunProposal(
|
|
58
|
+
"",
|
|
59
|
+
entry_type,
|
|
60
|
+
text,
|
|
61
|
+
"episode",
|
|
62
|
+
"Run memoryledger correction",
|
|
63
|
+
)
|
|
64
|
+
return None
|
|
65
|
+
if entry_type == "user_message":
|
|
66
|
+
if any(pattern in normalized for pattern in USER_PATTERNS) or (
|
|
67
|
+
"memoryledger" in normalized and "agents.md" in normalized
|
|
68
|
+
):
|
|
69
|
+
return RunProposal(
|
|
70
|
+
"",
|
|
71
|
+
entry_type,
|
|
72
|
+
text,
|
|
73
|
+
"episode",
|
|
74
|
+
"Run durable memory request",
|
|
75
|
+
)
|
|
76
|
+
return None
|
|
77
|
+
if entry_type == "command_outcome" and (
|
|
78
|
+
"manual_file" in normalized
|
|
79
|
+
or "manual.bak" in normalized
|
|
80
|
+
or ("memoryledger" in normalized and "export" in normalized)
|
|
81
|
+
):
|
|
82
|
+
return RunProposal(
|
|
83
|
+
"",
|
|
84
|
+
entry_type,
|
|
85
|
+
text,
|
|
86
|
+
"episode",
|
|
87
|
+
"Run memoryledger command outcome",
|
|
88
|
+
)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def decode_session(html: str) -> list[RunProposal] | None:
|
|
93
|
+
if len(html.encode()) > MAX_HTML_BYTES:
|
|
94
|
+
raise MemoryledgerError("RUN_TOO_LARGE", "run HTML exceeds size limit")
|
|
95
|
+
match = SESSION_RE.search(html)
|
|
96
|
+
if not match:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
decoded = base64.b64decode(match.group(1).strip(), validate=True)
|
|
100
|
+
if len(decoded) > MAX_PAYLOAD_BYTES:
|
|
101
|
+
raise MemoryledgerError(
|
|
102
|
+
"RUN_TOO_LARGE", "session payload exceeds size limit"
|
|
103
|
+
)
|
|
104
|
+
data = json.loads(decoded)
|
|
105
|
+
except MemoryledgerError:
|
|
106
|
+
raise
|
|
107
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
108
|
+
raise MemoryledgerError("INVALID_RUN_PAYLOAD", "invalid session-data") from exc
|
|
109
|
+
if not isinstance(data, dict) or data.get("schema") != "memoryledger.session.v1":
|
|
110
|
+
raise MemoryledgerError("UNSUPPORTED_RUN_SCHEMA", "unsupported session schema")
|
|
111
|
+
entries = data.get("entries")
|
|
112
|
+
if not isinstance(entries, list):
|
|
113
|
+
raise MemoryledgerError("INVALID_RUN_PAYLOAD", "entries must be a list")
|
|
114
|
+
proposals: list[RunProposal] = []
|
|
115
|
+
for raw in entries:
|
|
116
|
+
if not isinstance(raw, dict) or raw.get("type") not in ALLOWED_TYPES:
|
|
117
|
+
continue
|
|
118
|
+
entry_id = str(raw.get("id", ""))
|
|
119
|
+
text = str(raw.get("text", "")).strip()
|
|
120
|
+
if not entry_id or not text or len(text) > MAX_ENTRY_CHARS:
|
|
121
|
+
continue
|
|
122
|
+
proposal = _classify(str(raw["type"]), text)
|
|
123
|
+
if proposal is None:
|
|
124
|
+
continue
|
|
125
|
+
proposals.append(
|
|
126
|
+
RunProposal(
|
|
127
|
+
entry_id,
|
|
128
|
+
proposal.entry_type,
|
|
129
|
+
text,
|
|
130
|
+
proposal.kind,
|
|
131
|
+
proposal.title,
|
|
132
|
+
proposal.render_target,
|
|
133
|
+
str(raw.get("timestamp", "")),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return proposals
|