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