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/storage.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
import yaml
|
|
14
|
+
from ledgercore.atomic import atomic_write_text
|
|
15
|
+
from ledgercore.time import utc_now_iso
|
|
16
|
+
|
|
17
|
+
from .errors import MemoryledgerError
|
|
18
|
+
from .guardrails import confined_path, validate_memory, validate_scope_path
|
|
19
|
+
from .models import Config, EvidenceRef, Memory, RenderConfig, TemplatePolicy
|
|
20
|
+
|
|
21
|
+
CONFIG_NAMES = ("memoryledger.toml", ".memoryledger.toml")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dump_yaml(data: dict[str, object]) -> str:
|
|
25
|
+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
data = yaml.safe_load(path.read_text())
|
|
32
|
+
return data if isinstance(data, dict) else {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_evidence_refs(path: Path) -> list[EvidenceRef]:
|
|
36
|
+
raw = yaml.safe_load(path.read_text()) if path.exists() else {}
|
|
37
|
+
if isinstance(raw, dict):
|
|
38
|
+
items = raw.get("evidence", [])
|
|
39
|
+
elif isinstance(raw, list):
|
|
40
|
+
items = raw
|
|
41
|
+
else:
|
|
42
|
+
items = []
|
|
43
|
+
return [EvidenceRef.from_dict(item) for item in items if isinstance(item, dict)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_memory(path: Path) -> Memory:
|
|
47
|
+
data = _load_yaml(path / "memory.yaml")
|
|
48
|
+
if "evidence_refs" not in data:
|
|
49
|
+
refs = _load_evidence_refs(path / "evidence.yaml")
|
|
50
|
+
if refs:
|
|
51
|
+
data = {**data, "evidence_refs": [ref.to_dict() for ref in refs]}
|
|
52
|
+
return Memory.from_dict(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
56
|
+
data = tomllib.loads(path.read_text())
|
|
57
|
+
return data if isinstance(data, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _global_config_path() -> Path:
|
|
61
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
62
|
+
return base / "ledger" / "memoryledger.toml"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _merged_section(
|
|
66
|
+
global_data: dict[str, Any], project_data: dict[str, Any], key: str
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
merged: dict[str, Any] = {}
|
|
69
|
+
raw_global = global_data.get(key, {})
|
|
70
|
+
if isinstance(raw_global, dict):
|
|
71
|
+
merged.update(raw_global)
|
|
72
|
+
raw_project = project_data.get(key, {})
|
|
73
|
+
if isinstance(raw_project, dict):
|
|
74
|
+
merged.update(raw_project)
|
|
75
|
+
return merged
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_template_policy(data: dict[str, Any]) -> TemplatePolicy:
|
|
79
|
+
raw = data.get("template_policy", {})
|
|
80
|
+
if not isinstance(raw, dict):
|
|
81
|
+
return TemplatePolicy()
|
|
82
|
+
enabled_raw = raw.get("enabled", False)
|
|
83
|
+
ids: list[str] = []
|
|
84
|
+
enabled = False
|
|
85
|
+
if isinstance(enabled_raw, list):
|
|
86
|
+
ids.extend(str(value) for value in enabled_raw)
|
|
87
|
+
enabled = bool(ids)
|
|
88
|
+
else:
|
|
89
|
+
enabled = bool(enabled_raw)
|
|
90
|
+
raw_ids = raw.get("ids", [])
|
|
91
|
+
if isinstance(raw_ids, list):
|
|
92
|
+
for value in raw_ids:
|
|
93
|
+
item = str(value)
|
|
94
|
+
if item not in ids:
|
|
95
|
+
ids.append(item)
|
|
96
|
+
auto_accept = bool(raw.get("auto_accept", False))
|
|
97
|
+
return TemplatePolicy(
|
|
98
|
+
enabled=enabled or bool(ids), ids=ids, auto_accept=auto_accept
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def default_config_text(project_name: str, memoryledger_dir: str) -> str:
|
|
103
|
+
project_uuid = str(uuid.uuid4())
|
|
104
|
+
return f'''[ledger]
|
|
105
|
+
code = "ml"
|
|
106
|
+
name = "memoryledger"
|
|
107
|
+
|
|
108
|
+
[project]
|
|
109
|
+
name = "{project_name}"
|
|
110
|
+
uuid = "{project_uuid}"
|
|
111
|
+
|
|
112
|
+
[storage]
|
|
113
|
+
memoryledger_dir = "{memoryledger_dir}"
|
|
114
|
+
|
|
115
|
+
[render]
|
|
116
|
+
root_agents_path = "AGENTS.md"
|
|
117
|
+
linked_docs_dir = "docs/agents"
|
|
118
|
+
nested_agents_enabled = false
|
|
119
|
+
linked_docs_enabled = true
|
|
120
|
+
max_root_agents_chars = 12000
|
|
121
|
+
max_linked_doc_chars = 50000
|
|
122
|
+
include_local = false
|
|
123
|
+
include_rejected = false
|
|
124
|
+
sort_order = ["rule", "procedure", "semantic", "learning", "episode", "document"]
|
|
125
|
+
|
|
126
|
+
[intake]
|
|
127
|
+
allow_run_html = true
|
|
128
|
+
allow_current_run = true
|
|
129
|
+
default_review_status = "candidate"
|
|
130
|
+
'''
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
134
|
+
current = (start or Path.cwd()).resolve()
|
|
135
|
+
for directory in (current, *current.parents):
|
|
136
|
+
for name in CONFIG_NAMES:
|
|
137
|
+
path = directory / name
|
|
138
|
+
if path.exists():
|
|
139
|
+
return path
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_config(start: Path | None = None) -> Config:
|
|
144
|
+
path = find_config(start)
|
|
145
|
+
if path is None:
|
|
146
|
+
raise MemoryledgerError("NO_CONFIG", "Run `memoryledger init` first.")
|
|
147
|
+
project_data = _load_toml(path)
|
|
148
|
+
global_path = _global_config_path()
|
|
149
|
+
global_data = _load_toml(global_path) if global_path.exists() else {}
|
|
150
|
+
render_data = _merged_section(global_data, project_data, "render")
|
|
151
|
+
ledger_data = _merged_section(global_data, project_data, "ledger")
|
|
152
|
+
project_section = _merged_section(global_data, project_data, "project")
|
|
153
|
+
storage_data = _merged_section(global_data, project_data, "storage")
|
|
154
|
+
intake_data = _merged_section(global_data, project_data, "intake")
|
|
155
|
+
render = RenderConfig(
|
|
156
|
+
**{
|
|
157
|
+
k: v
|
|
158
|
+
for k, v in render_data.items()
|
|
159
|
+
if k in RenderConfig.__dataclass_fields__
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
config = Config(
|
|
163
|
+
root=path.parent,
|
|
164
|
+
config_path=path,
|
|
165
|
+
ledger_code=str(ledger_data.get("code", "ml")),
|
|
166
|
+
ledger_name=str(ledger_data.get("name", "memoryledger")),
|
|
167
|
+
project_name=str(project_section.get("name", "my-project")),
|
|
168
|
+
project_uuid=str(project_section.get("uuid", "")),
|
|
169
|
+
memoryledger_dir=str(storage_data.get("memoryledger_dir", ".memoryledger")),
|
|
170
|
+
render=render,
|
|
171
|
+
allow_run_html=bool(intake_data.get("allow_run_html", True)),
|
|
172
|
+
allow_current_run=bool(intake_data.get("allow_current_run", True)),
|
|
173
|
+
default_review_status=str(
|
|
174
|
+
intake_data.get("default_review_status", "candidate")
|
|
175
|
+
),
|
|
176
|
+
template_policy=_parse_template_policy(
|
|
177
|
+
{
|
|
178
|
+
"template_policy": {
|
|
179
|
+
**(
|
|
180
|
+
global_data.get("template_policy", {})
|
|
181
|
+
if isinstance(global_data.get("template_policy", {}), dict)
|
|
182
|
+
else {}
|
|
183
|
+
),
|
|
184
|
+
**(
|
|
185
|
+
project_data.get("template_policy", {})
|
|
186
|
+
if isinstance(project_data.get("template_policy", {}), dict)
|
|
187
|
+
else {}
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
confined_path(config.root, config.memoryledger_dir, label="memoryledger_dir")
|
|
194
|
+
confined_path(
|
|
195
|
+
config.root,
|
|
196
|
+
config.render.root_agents_path,
|
|
197
|
+
code="INVALID_OUTPUT_PATH",
|
|
198
|
+
label="root_agents_path",
|
|
199
|
+
)
|
|
200
|
+
confined_path(
|
|
201
|
+
config.root,
|
|
202
|
+
config.render.linked_docs_dir,
|
|
203
|
+
code="INVALID_OUTPUT_PATH",
|
|
204
|
+
label="linked_docs_dir",
|
|
205
|
+
)
|
|
206
|
+
if config.render.evidence_index_path:
|
|
207
|
+
confined_path(
|
|
208
|
+
config.root,
|
|
209
|
+
config.render.evidence_index_path,
|
|
210
|
+
code="INVALID_OUTPUT_PATH",
|
|
211
|
+
label="evidence_index_path",
|
|
212
|
+
)
|
|
213
|
+
return config
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def init_workspace(
|
|
217
|
+
project_name: str | None, memoryledger_dir: str, hidden_config: bool
|
|
218
|
+
) -> Config:
|
|
219
|
+
root = Path.cwd().resolve()
|
|
220
|
+
config_path = root / (
|
|
221
|
+
".memoryledger.toml" if hidden_config else "memoryledger.toml"
|
|
222
|
+
)
|
|
223
|
+
if not config_path.exists():
|
|
224
|
+
atomic_write_text(
|
|
225
|
+
config_path,
|
|
226
|
+
default_config_text(project_name or root.name, memoryledger_dir),
|
|
227
|
+
)
|
|
228
|
+
config = load_config(root)
|
|
229
|
+
ensure_storage(config)
|
|
230
|
+
return config
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def ensure_storage(config: Config) -> None:
|
|
234
|
+
for rel in ["memories", "imports", "rendered/docs", "rendered/nested"]:
|
|
235
|
+
(config.storage_dir / rel).mkdir(parents=True, exist_ok=True)
|
|
236
|
+
storage = config.storage_dir / "storage.yaml"
|
|
237
|
+
if not storage.exists():
|
|
238
|
+
atomic_write_text(
|
|
239
|
+
storage, _dump_yaml({"next_memory_number": 1, "next_import_number": 1})
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Store:
|
|
244
|
+
def __init__(self, config: Config) -> None:
|
|
245
|
+
self.config = config
|
|
246
|
+
ensure_storage(config)
|
|
247
|
+
|
|
248
|
+
def storage_meta(self) -> dict[str, Any]:
|
|
249
|
+
return _load_yaml(self.config.storage_dir / "storage.yaml")
|
|
250
|
+
|
|
251
|
+
def write_storage_meta(self, data: dict[str, object]) -> None:
|
|
252
|
+
atomic_write_text(self.config.storage_dir / "storage.yaml", _dump_yaml(data))
|
|
253
|
+
|
|
254
|
+
def next_id(self) -> str:
|
|
255
|
+
meta = self.storage_meta()
|
|
256
|
+
number = int(meta.get("next_memory_number", 1))
|
|
257
|
+
meta["next_memory_number"] = number + 1
|
|
258
|
+
self.write_storage_meta(meta)
|
|
259
|
+
return f"memory-{number:04d}"
|
|
260
|
+
|
|
261
|
+
def memory_dir(self, memory_id: str) -> Path:
|
|
262
|
+
return self.config.storage_dir / "memories" / memory_id
|
|
263
|
+
|
|
264
|
+
def all_memories(self) -> list[Memory]:
|
|
265
|
+
items: list[Memory] = []
|
|
266
|
+
base = self.config.storage_dir / "memories"
|
|
267
|
+
for path in sorted(base.glob("memory-*/memory.yaml")):
|
|
268
|
+
items.append(_load_memory(path.parent))
|
|
269
|
+
return items
|
|
270
|
+
|
|
271
|
+
def get(self, memory_id: str) -> Memory:
|
|
272
|
+
path = self.memory_dir(memory_id) / "memory.yaml"
|
|
273
|
+
if not path.exists():
|
|
274
|
+
raise MemoryledgerError("NOT_FOUND", f"Memory not found: {memory_id}")
|
|
275
|
+
return _load_memory(path.parent)
|
|
276
|
+
|
|
277
|
+
def read_content(self, memory_id: str) -> str:
|
|
278
|
+
return (self.memory_dir(memory_id) / "content.md").read_text()
|
|
279
|
+
|
|
280
|
+
def read_evidence(self, memory_id: str) -> str:
|
|
281
|
+
path = self.memory_dir(memory_id) / "evidence.md"
|
|
282
|
+
return path.read_text() if path.exists() else ""
|
|
283
|
+
|
|
284
|
+
def create(
|
|
285
|
+
self,
|
|
286
|
+
kind: str,
|
|
287
|
+
title: str,
|
|
288
|
+
content: str,
|
|
289
|
+
evidence: str,
|
|
290
|
+
scope: str,
|
|
291
|
+
scope_path: str,
|
|
292
|
+
render_target: str,
|
|
293
|
+
source: str = "cli",
|
|
294
|
+
*,
|
|
295
|
+
origin: str = "",
|
|
296
|
+
origin_hash: str = "",
|
|
297
|
+
section: str = "",
|
|
298
|
+
evidence_refs: list[EvidenceRef] | None = None,
|
|
299
|
+
) -> Memory:
|
|
300
|
+
memory = self.validate_new(
|
|
301
|
+
kind,
|
|
302
|
+
title,
|
|
303
|
+
content,
|
|
304
|
+
evidence,
|
|
305
|
+
scope,
|
|
306
|
+
scope_path,
|
|
307
|
+
render_target,
|
|
308
|
+
source,
|
|
309
|
+
origin=origin,
|
|
310
|
+
origin_hash=origin_hash,
|
|
311
|
+
section=section,
|
|
312
|
+
evidence_refs=evidence_refs,
|
|
313
|
+
)
|
|
314
|
+
memory = replace(memory, id=self.next_id())
|
|
315
|
+
self.write(memory, content, evidence, "created")
|
|
316
|
+
return memory
|
|
317
|
+
|
|
318
|
+
def validate_new(
|
|
319
|
+
self,
|
|
320
|
+
kind: str,
|
|
321
|
+
title: str,
|
|
322
|
+
content: str,
|
|
323
|
+
evidence: str,
|
|
324
|
+
scope: str,
|
|
325
|
+
scope_path: str,
|
|
326
|
+
render_target: str,
|
|
327
|
+
source: str = "cli",
|
|
328
|
+
*,
|
|
329
|
+
origin: str = "",
|
|
330
|
+
origin_hash: str = "",
|
|
331
|
+
section: str = "",
|
|
332
|
+
evidence_refs: list[EvidenceRef] | None = None,
|
|
333
|
+
) -> Memory:
|
|
334
|
+
now = utc_now_iso()
|
|
335
|
+
memory = Memory(
|
|
336
|
+
"memory-pending",
|
|
337
|
+
kind,
|
|
338
|
+
title,
|
|
339
|
+
"candidate",
|
|
340
|
+
100,
|
|
341
|
+
scope,
|
|
342
|
+
scope_path,
|
|
343
|
+
render_target,
|
|
344
|
+
source,
|
|
345
|
+
now,
|
|
346
|
+
now,
|
|
347
|
+
1,
|
|
348
|
+
[],
|
|
349
|
+
origin,
|
|
350
|
+
origin_hash,
|
|
351
|
+
section,
|
|
352
|
+
evidence_refs or [],
|
|
353
|
+
)
|
|
354
|
+
validate_scope_path(self.config.root, scope_path)
|
|
355
|
+
validate_memory(memory, content, evidence, self.config.root)
|
|
356
|
+
return memory
|
|
357
|
+
|
|
358
|
+
def write(self, memory: Memory, content: str, evidence: str, reason: str) -> None:
|
|
359
|
+
validate_scope_path(self.config.root, memory.scope_path)
|
|
360
|
+
validate_memory(memory, content, evidence, self.config.root)
|
|
361
|
+
mdir = self.memory_dir(memory.id)
|
|
362
|
+
(mdir / "versions").mkdir(parents=True, exist_ok=True)
|
|
363
|
+
atomic_write_text(mdir / "memory.yaml", _dump_yaml(memory.to_dict()))
|
|
364
|
+
atomic_write_text(mdir / "content.md", content.rstrip() + "\n")
|
|
365
|
+
atomic_write_text(
|
|
366
|
+
mdir / "evidence.md", evidence.rstrip() + "\n" if evidence else ""
|
|
367
|
+
)
|
|
368
|
+
evidence_yaml = mdir / "evidence.yaml"
|
|
369
|
+
if memory.evidence_refs:
|
|
370
|
+
atomic_write_text(
|
|
371
|
+
evidence_yaml,
|
|
372
|
+
_dump_yaml(
|
|
373
|
+
{"evidence": [ref.to_dict() for ref in memory.evidence_refs]}
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
elif evidence_yaml.exists():
|
|
377
|
+
evidence_yaml.unlink()
|
|
378
|
+
version = f"v{memory.version:04d}"
|
|
379
|
+
atomic_write_text(
|
|
380
|
+
mdir / "versions" / f"{version}.yaml",
|
|
381
|
+
_dump_yaml(
|
|
382
|
+
{"memory": memory.to_dict(), "reason": reason, "evidence": evidence}
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
atomic_write_text(mdir / "versions" / f"{version}.md", content.rstrip() + "\n")
|
|
386
|
+
|
|
387
|
+
def update_status(self, memory_id: str, status: str, reason: str) -> Memory:
|
|
388
|
+
old = self.get(memory_id)
|
|
389
|
+
refs = old.evidence_refs
|
|
390
|
+
if status == "accepted":
|
|
391
|
+
refs = [
|
|
392
|
+
*refs,
|
|
393
|
+
EvidenceRef(
|
|
394
|
+
kind="user_approval",
|
|
395
|
+
title="Review approval",
|
|
396
|
+
uri=f"memory:{memory_id}#review-v{old.version + 1:04d}",
|
|
397
|
+
excerpt=reason,
|
|
398
|
+
timestamp=utc_now_iso(),
|
|
399
|
+
),
|
|
400
|
+
]
|
|
401
|
+
new = replace(
|
|
402
|
+
old,
|
|
403
|
+
status=status,
|
|
404
|
+
evidence_refs=refs,
|
|
405
|
+
updated_at=utc_now_iso(),
|
|
406
|
+
version=old.version + 1,
|
|
407
|
+
)
|
|
408
|
+
self.write(
|
|
409
|
+
new,
|
|
410
|
+
self.read_content(memory_id),
|
|
411
|
+
self.read_evidence(memory_id) + f"\nReview reason: {reason}\n",
|
|
412
|
+
reason,
|
|
413
|
+
)
|
|
414
|
+
return new
|
|
415
|
+
|
|
416
|
+
def update_content(
|
|
417
|
+
self,
|
|
418
|
+
memory_id: str,
|
|
419
|
+
content: str,
|
|
420
|
+
reason: str,
|
|
421
|
+
append: bool = False,
|
|
422
|
+
section: str | None = None,
|
|
423
|
+
) -> Memory:
|
|
424
|
+
old = self.get(memory_id)
|
|
425
|
+
body = (
|
|
426
|
+
self.read_content(memory_id).rstrip() + "\n" + content.rstrip() + "\n"
|
|
427
|
+
if append
|
|
428
|
+
else content.rstrip() + "\n"
|
|
429
|
+
)
|
|
430
|
+
new = replace(
|
|
431
|
+
old,
|
|
432
|
+
section=old.section if section is None else section,
|
|
433
|
+
updated_at=utc_now_iso(),
|
|
434
|
+
version=old.version + 1,
|
|
435
|
+
)
|
|
436
|
+
self.write(new, body, self.read_evidence(memory_id), reason)
|
|
437
|
+
return new
|
|
438
|
+
|
|
439
|
+
def add_evidence(
|
|
440
|
+
self, memory_id: str, evidence: EvidenceRef, reason: str
|
|
441
|
+
) -> Memory:
|
|
442
|
+
if not reason.strip():
|
|
443
|
+
raise MemoryledgerError(
|
|
444
|
+
"MISSING_REASON", "evidence changes require a reason"
|
|
445
|
+
)
|
|
446
|
+
old = self.get(memory_id)
|
|
447
|
+
new = replace(
|
|
448
|
+
old,
|
|
449
|
+
evidence_refs=[*old.evidence_refs, evidence],
|
|
450
|
+
updated_at=utc_now_iso(),
|
|
451
|
+
version=old.version + 1,
|
|
452
|
+
)
|
|
453
|
+
self.write(
|
|
454
|
+
new, self.read_content(memory_id), self.read_evidence(memory_id), reason
|
|
455
|
+
)
|
|
456
|
+
return new
|
|
457
|
+
|
|
458
|
+
def next_import_id(self) -> str:
|
|
459
|
+
meta = self.storage_meta()
|
|
460
|
+
number = int(meta.get("next_import_number", 1))
|
|
461
|
+
meta["next_import_number"] = number + 1
|
|
462
|
+
self.write_storage_meta(meta)
|
|
463
|
+
return f"import-{number:04d}"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
12
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
13
|
+
|
|
14
|
+
from ledgercore.time import utc_now_iso
|
|
15
|
+
|
|
16
|
+
from .errors import MemoryledgerError
|
|
17
|
+
from .guardrails import confined_path, validate_content
|
|
18
|
+
from .models import GlobalConfig, Memory, Template
|
|
19
|
+
from .storage import Store
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def global_config_path() -> Path:
|
|
23
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
24
|
+
return base / "ledger" / "memoryledger.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonical_hash(content: str) -> str:
|
|
28
|
+
return hashlib.sha256((content.rstrip() + "\n").encode()).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_global_config(path: Path | None = None) -> GlobalConfig:
|
|
32
|
+
config_path = path or global_config_path()
|
|
33
|
+
if not config_path.exists():
|
|
34
|
+
return GlobalConfig(config_path)
|
|
35
|
+
data = tomllib.loads(config_path.read_text())
|
|
36
|
+
raw_templates = data.get("templates", [])
|
|
37
|
+
if isinstance(raw_templates, dict):
|
|
38
|
+
raw_templates = [
|
|
39
|
+
{"id": template_id, **value} for template_id, value in raw_templates.items()
|
|
40
|
+
]
|
|
41
|
+
templates: list[Template] = []
|
|
42
|
+
seen: set[str] = set()
|
|
43
|
+
for raw in raw_templates:
|
|
44
|
+
if not isinstance(raw, dict):
|
|
45
|
+
raise MemoryledgerError("INVALID_TEMPLATE", "template must be a table")
|
|
46
|
+
template = _parse_template(raw, config_path.parent)
|
|
47
|
+
if template.id in seen:
|
|
48
|
+
raise MemoryledgerError(
|
|
49
|
+
"DUPLICATE_TEMPLATE_ID", f"Duplicate template ID: {template.id}"
|
|
50
|
+
)
|
|
51
|
+
seen.add(template.id)
|
|
52
|
+
templates.append(template)
|
|
53
|
+
return GlobalConfig(config_path, templates)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_template(raw: dict[str, Any], root: Path) -> Template:
|
|
57
|
+
template = Template(
|
|
58
|
+
id=str(raw.get("id", "")),
|
|
59
|
+
version=str(raw.get("version", "1")),
|
|
60
|
+
kind=str(raw.get("kind", "rule")),
|
|
61
|
+
title=str(raw.get("title", "")),
|
|
62
|
+
content=str(raw.get("content", "")),
|
|
63
|
+
content_file=str(raw.get("content_file", "")),
|
|
64
|
+
scope=str(raw.get("scope", "global")),
|
|
65
|
+
scope_path=str(raw.get("scope_path", "")),
|
|
66
|
+
render_target=str(raw.get("render_target", "root_agents")),
|
|
67
|
+
section=str(raw.get("section", "")),
|
|
68
|
+
source_root=root,
|
|
69
|
+
)
|
|
70
|
+
if not template.id or not template.title:
|
|
71
|
+
raise MemoryledgerError(
|
|
72
|
+
"INVALID_TEMPLATE", "template ID and title are required"
|
|
73
|
+
)
|
|
74
|
+
return template
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def template_content(template: Template) -> str:
|
|
78
|
+
if bool(template.content) == bool(template.content_file):
|
|
79
|
+
raise MemoryledgerError(
|
|
80
|
+
"INVALID_TEMPLATE", "provide exactly one of content or content_file"
|
|
81
|
+
)
|
|
82
|
+
content = template.content
|
|
83
|
+
if template.content_file:
|
|
84
|
+
path = confined_path(
|
|
85
|
+
template.source_root,
|
|
86
|
+
template.content_file,
|
|
87
|
+
code="INVALID_TEMPLATE_PATH",
|
|
88
|
+
label="template content_file",
|
|
89
|
+
must_exist=True,
|
|
90
|
+
)
|
|
91
|
+
content = path.read_text()
|
|
92
|
+
validate_content(content)
|
|
93
|
+
return content.rstrip() + "\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_template(config: GlobalConfig, template_id: str) -> Template:
|
|
97
|
+
for template in config.templates:
|
|
98
|
+
if template.id == template_id:
|
|
99
|
+
return template
|
|
100
|
+
raise MemoryledgerError("TEMPLATE_NOT_FOUND", template_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def apply_template(store: Store, template: Template) -> tuple[str, Memory]:
|
|
104
|
+
content = template_content(template)
|
|
105
|
+
digest = canonical_hash(content)
|
|
106
|
+
origin = f"template:{template.id}"
|
|
107
|
+
matches = [memory for memory in store.all_memories() if memory.origin == origin]
|
|
108
|
+
if not matches:
|
|
109
|
+
memory = store.create(
|
|
110
|
+
template.kind,
|
|
111
|
+
template.title,
|
|
112
|
+
content,
|
|
113
|
+
f"Applied template {template.id} version {template.version}.",
|
|
114
|
+
template.scope,
|
|
115
|
+
template.scope_path,
|
|
116
|
+
template.render_target,
|
|
117
|
+
"template",
|
|
118
|
+
origin=origin,
|
|
119
|
+
origin_hash=digest,
|
|
120
|
+
section=template.section,
|
|
121
|
+
)
|
|
122
|
+
return "created", memory
|
|
123
|
+
memory = matches[0]
|
|
124
|
+
local = store.read_content(memory.id)
|
|
125
|
+
if canonical_hash(local) != memory.origin_hash:
|
|
126
|
+
raise MemoryledgerError(
|
|
127
|
+
"TEMPLATE_CONFLICT", f"Local memory diverged from template: {memory.id}"
|
|
128
|
+
)
|
|
129
|
+
if memory.origin_hash == digest:
|
|
130
|
+
return "unchanged", memory
|
|
131
|
+
updated = replace(
|
|
132
|
+
memory,
|
|
133
|
+
kind=template.kind,
|
|
134
|
+
title=template.title,
|
|
135
|
+
status="candidate",
|
|
136
|
+
scope=template.scope,
|
|
137
|
+
scope_path=template.scope_path,
|
|
138
|
+
render_target=template.render_target,
|
|
139
|
+
section=template.section,
|
|
140
|
+
origin_hash=digest,
|
|
141
|
+
updated_at=utc_now_iso(),
|
|
142
|
+
version=memory.version + 1,
|
|
143
|
+
)
|
|
144
|
+
store.write(
|
|
145
|
+
updated,
|
|
146
|
+
content,
|
|
147
|
+
f"Synced template {template.id} version {template.version}.",
|
|
148
|
+
f"template sync {template.version}",
|
|
149
|
+
)
|
|
150
|
+
return "updated", updated
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def remove_template(store: Store, template_id: str, reason: str) -> Memory:
|
|
154
|
+
matches = [
|
|
155
|
+
memory
|
|
156
|
+
for memory in store.all_memories()
|
|
157
|
+
if memory.origin == f"template:{template_id}"
|
|
158
|
+
]
|
|
159
|
+
if not matches:
|
|
160
|
+
raise MemoryledgerError("TEMPLATE_NOT_APPLIED", template_id)
|
|
161
|
+
return store.update_status(matches[0].id, "archived", reason)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memoryledger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auditable long-term project memory ledger and AGENTS.md renderer
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: typer
|
|
10
|
+
Requires-Dist: ledgercore<0.3,>=0.2
|
|
11
|
+
Requires-Dist: PyYAML
|
|
12
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build; extra == "dev"
|
|
15
|
+
Requires-Dist: mypy; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-xdist; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff; extra == "dev"
|
|
19
|
+
Requires-Dist: twine; extra == "dev"
|
|
20
|
+
Requires-Dist: types-PyYAML; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# memoryledger
|
|
24
|
+
|
|
25
|
+
`memoryledger` is an auditable long-term project memory ledger and deterministic `AGENTS.md` renderer.
|
|
26
|
+
|
|
27
|
+
`AGENTS.md` is a generated entry point. Canonical durable memory lives in
|
|
28
|
+
`.memoryledger/`.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
memoryledger init
|
|
34
|
+
memoryledger memory create --kind rule --title "Use plans" --text "Always plan before implementation."
|
|
35
|
+
memoryledger review accept memory-0001 --reason "User approved project rule."
|
|
36
|
+
memoryledger render --print
|
|
37
|
+
memoryledger export
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The short alias `memledger` exposes the same CLI.
|
|
41
|
+
|
|
42
|
+
To build or update `AGENTS.md`, update memory records first, then render and
|
|
43
|
+
export. Do not edit generated `AGENTS.md` files directly.
|
|
44
|
+
|
|
45
|
+
Rendered targets are owned only when configured and marked with
|
|
46
|
+
`<!-- Generated by memoryledger. Do not edit directly. -->`. Do not edit those
|
|
47
|
+
files directly. Files under `docs/agents/` without the marker may be manual.
|
|
48
|
+
Export refuses to overwrite any manual target, including when `--backup` is
|
|
49
|
+
passed. Use `memoryledger agents adopt` for a read-only preview and
|
|
50
|
+
`memoryledger agents adopt --apply --backup` for the dedicated adoption
|
|
51
|
+
transaction. Adoption creates candidates by default; `--accept` requires
|
|
52
|
+
`--reason`.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
memoryledger memory create --kind rule --title "Use memoryledger for AGENTS" --text "Do not edit AGENTS.md directly; update memoryledger records and export."
|
|
58
|
+
memoryledger review accept memory-0001 --reason "User approved project memory workflow."
|
|
59
|
+
memoryledger render
|
|
60
|
+
memoryledger export
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Additional intake commands are `templates list/show/apply/sync/remove`,
|
|
64
|
+
`evidence scan`, and `import run-html`. All producer output remains candidate
|
|
65
|
+
memory until explicit review. Structured evidence is managed with
|
|
66
|
+
`memory evidence list/add`. Both `memoryledger` and `memledger` expose the same
|
|
67
|
+
commands.
|