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/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class MemoryledgerError(Exception):
8
+ code: str
9
+ message: str
10
+ details: dict[str, object] | None = None
11
+
12
+ def __str__(self) -> str:
13
+ return f"{self.code}: {self.message}"
14
+
15
+
16
+ def require(condition: bool, code: str, message: str) -> None:
17
+ if not condition:
18
+ raise MemoryledgerError(code, message)
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .models import EvidenceRef
8
+ from .storage import Store
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ScanProposal:
13
+ scanner: str
14
+ path: str
15
+ fact_key: str
16
+ title: str
17
+ kind: str
18
+ observed: str
19
+ suggested: str
20
+ line: int
21
+ source_hash: str
22
+ render_target: str = "root_agents"
23
+
24
+ @property
25
+ def origin(self) -> str:
26
+ return f"scan:{self.scanner}:{self.path}:{self.fact_key}"
27
+
28
+ def to_dict(self) -> dict[str, object]:
29
+ return {
30
+ "origin": self.origin,
31
+ "path": self.path,
32
+ "fact_key": self.fact_key,
33
+ "title": self.title,
34
+ "kind": self.kind,
35
+ "observed": self.observed,
36
+ "suggested": self.suggested,
37
+ "line": self.line,
38
+ "source_hash": self.source_hash,
39
+ "render_target": self.render_target,
40
+ }
41
+
42
+
43
+ def scan(root: Path) -> list[ScanProposal]:
44
+ proposals: list[ScanProposal] = []
45
+ pyproject = root / "pyproject.toml"
46
+ if pyproject.exists():
47
+ lines = pyproject.read_text().splitlines()
48
+ for number, line in enumerate(lines, 1):
49
+ stripped = line.strip()
50
+ if stripped.startswith("requires-python"):
51
+ observed = stripped
52
+ proposals.append(
53
+ _proposal(
54
+ "pyproject",
55
+ "pyproject.toml",
56
+ "requires-python",
57
+ "Python requirement",
58
+ "learning",
59
+ observed,
60
+ f"Project Python requirement is `{observed.split('=', 1)[-1].strip()}`.",
61
+ number,
62
+ )
63
+ )
64
+ break
65
+ section_headers = {
66
+ "[tool.mypy]": (
67
+ "tool.mypy",
68
+ "Python type checking",
69
+ "procedure",
70
+ "Run `mypy memoryledger` when changing typed public APIs or core logic.",
71
+ "linked_doc",
72
+ ),
73
+ "[tool.ruff]": (
74
+ "tool.ruff",
75
+ "Ruff validation",
76
+ "procedure",
77
+ "Run `ruff check .` when Python code or lint configuration changes.",
78
+ "linked_doc",
79
+ ),
80
+ "[tool.ruff.lint]": (
81
+ "tool.ruff.lint",
82
+ "Ruff validation",
83
+ "procedure",
84
+ "Run `ruff check .` when Python code or lint configuration changes.",
85
+ "linked_doc",
86
+ ),
87
+ "[tool.pytest.ini_options]": (
88
+ "tool.pytest.ini_options",
89
+ "Pytest validation",
90
+ "procedure",
91
+ "Run `pytest -q` and any focused pytest selection that covers the changed behavior.",
92
+ "linked_doc",
93
+ ),
94
+ }
95
+ for number, line in enumerate(lines, 1):
96
+ stripped = line.strip()
97
+ if stripped not in section_headers:
98
+ continue
99
+ key, title, kind, suggested, render_target = section_headers[stripped]
100
+ proposals.append(
101
+ _proposal(
102
+ "pyproject",
103
+ "pyproject.toml",
104
+ key,
105
+ title,
106
+ kind,
107
+ stripped,
108
+ suggested,
109
+ number,
110
+ render_target=render_target,
111
+ )
112
+ )
113
+ for name in (".pre-commit-config.yaml", ".pre-commit-config.yml"):
114
+ pre_commit = root / name
115
+ if not pre_commit.exists():
116
+ continue
117
+ line = 1
118
+ for number, raw in enumerate(pre_commit.read_text().splitlines(), 1):
119
+ if raw.strip():
120
+ line = number
121
+ break
122
+ proposals.append(
123
+ _proposal(
124
+ "pre-commit",
125
+ name,
126
+ "pre-commit-hooks",
127
+ "Pre-commit validation",
128
+ "procedure",
129
+ "configured hooks",
130
+ "Run `pre-commit run --all-files` when changes affect repository-wide formatting, linting, or configured hooks.",
131
+ line,
132
+ render_target="linked_doc",
133
+ )
134
+ )
135
+ break
136
+ return sorted(proposals, key=lambda item: item.origin)
137
+
138
+
139
+ def _proposal(
140
+ scanner: str,
141
+ path: str,
142
+ key: str,
143
+ title: str,
144
+ kind: str,
145
+ observed: str,
146
+ suggested: str,
147
+ line: int,
148
+ *,
149
+ render_target: str = "root_agents",
150
+ ) -> ScanProposal:
151
+ digest = hashlib.sha256(observed.encode()).hexdigest()
152
+ return ScanProposal(
153
+ scanner,
154
+ path,
155
+ key,
156
+ title,
157
+ kind,
158
+ observed,
159
+ suggested,
160
+ line,
161
+ digest,
162
+ render_target,
163
+ )
164
+
165
+
166
+ def apply(store: Store, proposals: list[ScanProposal]) -> list[dict[str, str]]:
167
+ results: list[dict[str, str]] = []
168
+ by_origin = {memory.origin: memory for memory in store.all_memories()}
169
+ for proposal in proposals:
170
+ old = by_origin.get(proposal.origin)
171
+ if old and old.origin_hash == proposal.source_hash:
172
+ results.append({"action": "unchanged", "memory_id": old.id})
173
+ continue
174
+ evidence = EvidenceRef(
175
+ kind="file",
176
+ title="Observed configuration",
177
+ uri=proposal.path,
178
+ excerpt=proposal.observed,
179
+ line_start=proposal.line,
180
+ line_end=proposal.line,
181
+ )
182
+ if old:
183
+ from dataclasses import replace
184
+
185
+ from ledgercore.time import utc_now_iso
186
+
187
+ updated = replace(
188
+ old,
189
+ kind=proposal.kind,
190
+ title=proposal.title,
191
+ status="candidate",
192
+ scope_path=proposal.path,
193
+ render_target=proposal.render_target,
194
+ origin_hash=proposal.source_hash,
195
+ evidence_refs=[evidence],
196
+ updated_at=utc_now_iso(),
197
+ version=old.version + 1,
198
+ )
199
+ store.write(
200
+ updated, proposal.suggested, "Scanner fact changed.", "scan update"
201
+ )
202
+ results.append({"action": "updated", "memory_id": old.id})
203
+ else:
204
+ memory = store.create(
205
+ proposal.kind,
206
+ proposal.title,
207
+ proposal.suggested,
208
+ "Detected from repository configuration.",
209
+ "repo",
210
+ proposal.path,
211
+ proposal.render_target,
212
+ "scan",
213
+ origin=proposal.origin,
214
+ origin_hash=proposal.source_hash,
215
+ evidence_refs=[evidence],
216
+ )
217
+ results.append({"action": "created", "memory_id": memory.id})
218
+ return results
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .errors import MemoryledgerError
7
+ from .models import (
8
+ EVIDENCE_KINDS,
9
+ GENERATED_MARKER,
10
+ KINDS,
11
+ RENDER_TARGETS,
12
+ SCOPES,
13
+ STATUSES,
14
+ EvidenceRef,
15
+ Memory,
16
+ )
17
+
18
+ SECRET_RE = re.compile(
19
+ r"(?i)(api[_-]?key|secret|password|token)\s*[=:]\s*['\"]?[A-Za-z0-9_./+-]{12,}"
20
+ )
21
+ PLACEHOLDER_RE = re.compile(r"\b(TBD|TODO:|<fill>)\b")
22
+
23
+
24
+ def confined_path(
25
+ root: Path,
26
+ value: str | Path,
27
+ *,
28
+ code: str = "INVALID_PATH",
29
+ label: str = "path",
30
+ must_exist: bool = False,
31
+ ) -> Path:
32
+ raw = Path(value)
33
+ if raw.is_absolute() or ".." in raw.parts:
34
+ raise MemoryledgerError(code, f"{label} must be relative to its owning root")
35
+ target = (root.resolve() / raw).resolve()
36
+ try:
37
+ target.relative_to(root.resolve())
38
+ except ValueError as exc:
39
+ raise MemoryledgerError(
40
+ code, f"{label} must not escape its owning root"
41
+ ) from exc
42
+ if must_exist and not target.exists():
43
+ raise MemoryledgerError(code, f"{label} does not exist: {value}")
44
+ return target
45
+
46
+
47
+ def validate_scope_path(root: Path, scope_path: str) -> None:
48
+ if scope_path:
49
+ confined_path(root, scope_path, code="INVALID_SCOPE_PATH", label="scope_path")
50
+
51
+
52
+ def validate_content(content: str) -> None:
53
+ if not content.strip():
54
+ raise MemoryledgerError("EMPTY_CONTENT", "content.md must be non-empty")
55
+ if SECRET_RE.search(content):
56
+ raise MemoryledgerError(
57
+ "SECRET_LIKE_CONTENT", "content contains secret-like text"
58
+ )
59
+ if PLACEHOLDER_RE.search(content):
60
+ raise MemoryledgerError(
61
+ "PLACEHOLDER_CONTENT", "content contains unresolved placeholders"
62
+ )
63
+ if len(content) > 200_000 or content.lower().count("assistant") > 100:
64
+ raise MemoryledgerError(
65
+ "TRANSCRIPT_LIKE_CONTENT", "content looks like a huge pasted transcript"
66
+ )
67
+
68
+
69
+ def validate_evidence_ref(root: Path, evidence: EvidenceRef) -> None:
70
+ if evidence.kind not in EVIDENCE_KINDS:
71
+ raise MemoryledgerError("INVALID_EVIDENCE_KIND", evidence.kind)
72
+ if not evidence.title.strip() or not evidence.uri.strip():
73
+ raise MemoryledgerError(
74
+ "INVALID_EVIDENCE", "evidence title and URI are required"
75
+ )
76
+ if len(evidence.excerpt) > 2000:
77
+ raise MemoryledgerError("EVIDENCE_TOO_LARGE", "evidence excerpt is too large")
78
+ if SECRET_RE.search(evidence.excerpt):
79
+ raise MemoryledgerError(
80
+ "SECRET_LIKE_EVIDENCE", "evidence contains secret-like text"
81
+ )
82
+ if evidence.line_start is not None and evidence.line_start < 1:
83
+ raise MemoryledgerError("INVALID_LINE_RANGE", "line_start must be positive")
84
+ if evidence.line_end is not None and (
85
+ evidence.line_start is None or evidence.line_end < evidence.line_start
86
+ ):
87
+ raise MemoryledgerError("INVALID_LINE_RANGE", "invalid evidence line range")
88
+ if evidence.kind in {"file", "config"}:
89
+ confined_path(
90
+ root, evidence.uri, code="INVALID_EVIDENCE_URI", label="evidence URI"
91
+ )
92
+ if evidence.kind == "run" and not re.fullmatch(
93
+ r"run:[A-Za-z0-9._-]+#[A-Za-z0-9._-]+", evidence.uri
94
+ ):
95
+ raise MemoryledgerError("INVALID_EVIDENCE_URI", "invalid internal run URI")
96
+
97
+
98
+ def validate_memory(
99
+ memory: Memory, content: str, evidence: str = "", root: Path | None = None
100
+ ) -> None:
101
+ if memory.kind not in KINDS:
102
+ raise MemoryledgerError("INVALID_KIND", f"Invalid kind: {memory.kind}")
103
+ if memory.status not in STATUSES:
104
+ raise MemoryledgerError("INVALID_STATUS", f"Invalid status: {memory.status}")
105
+ if memory.scope not in SCOPES:
106
+ raise MemoryledgerError("INVALID_SCOPE", f"Invalid scope: {memory.scope}")
107
+ if memory.render_target not in RENDER_TARGETS:
108
+ raise MemoryledgerError(
109
+ "INVALID_RENDER_TARGET", f"Invalid render target: {memory.render_target}"
110
+ )
111
+ if not memory.title.strip():
112
+ raise MemoryledgerError("EMPTY_TITLE", "title must be non-empty")
113
+ if memory.section and (
114
+ len(memory.section) > 80
115
+ or "\n" in memory.section
116
+ or not re.fullmatch(r"[\w .&/+()-]+", memory.section)
117
+ ):
118
+ raise MemoryledgerError("INVALID_SECTION", "section name is invalid")
119
+ validate_content(content)
120
+ if evidence and SECRET_RE.search(evidence):
121
+ raise MemoryledgerError(
122
+ "SECRET_LIKE_EVIDENCE", "evidence contains secret-like text"
123
+ )
124
+ for ref in memory.evidence_refs:
125
+ validate_evidence_ref(root or Path.cwd(), ref)
126
+ if memory.status == "accepted" and not (evidence.strip() or memory.evidence_refs):
127
+ raise MemoryledgerError(
128
+ "MISSING_EVIDENCE", "accepted memory must have evidence or approval reason"
129
+ )
130
+
131
+
132
+ def validate_generated_text(
133
+ text: str, max_chars: int, require_marker: bool = True
134
+ ) -> None:
135
+ if require_marker and GENERATED_MARKER not in text:
136
+ raise MemoryledgerError(
137
+ "MISSING_MARKER", "generated output must contain marker"
138
+ )
139
+ if len(text) > max_chars:
140
+ raise MemoryledgerError(
141
+ "OUTPUT_TOO_LARGE", "generated output exceeds configured limit"
142
+ )
143
+ if "run.html" in text.lower():
144
+ raise MemoryledgerError(
145
+ "RAW_RUN_HTML", "generated output must not contain raw run.html"
146
+ )
147
+ if SECRET_RE.search(text):
148
+ raise MemoryledgerError(
149
+ "SECRET_LIKE_OUTPUT", "generated output contains secret-like text"
150
+ )
151
+
152
+
153
+ def safe_to_replace(path: Path) -> None:
154
+ if path.exists() and GENERATED_MARKER not in path.read_text():
155
+ raise MemoryledgerError(
156
+ "MANUAL_FILE", f"Refusing to overwrite manual file: {path}"
157
+ )
memoryledger/intake.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from html import unescape
6
+ from pathlib import Path
7
+
8
+ from ledgercore.atomic import atomic_write_text
9
+
10
+ from .models import EvidenceRef
11
+ from .run_import import decode_session
12
+ from .storage import Store
13
+
14
+
15
+ def import_text(store: Store, text: str, source: str = "import") -> list[str]:
16
+ body = text.strip()
17
+ title = next(
18
+ (line.strip("# ") for line in body.splitlines() if line.strip()),
19
+ "Imported memory",
20
+ )[:80]
21
+ memory = store.create(
22
+ "learning",
23
+ title,
24
+ body,
25
+ "Imported from text.",
26
+ "global",
27
+ "",
28
+ "root_agents",
29
+ source,
30
+ )
31
+ return [memory.id]
32
+
33
+
34
+ def import_run_html(store: Store, path: Path) -> list[str]:
35
+ raw = path.read_text(errors="replace")
36
+ proposals = decode_session(raw)
37
+ if proposals is not None:
38
+ import_key = hashlib.sha256(raw.encode()).hexdigest()[:16]
39
+ existing = {memory.origin: memory for memory in store.all_memories()}
40
+ for proposal in proposals:
41
+ origin = f"run:{import_key}:{proposal.entry_id}"
42
+ if origin in existing:
43
+ continue
44
+ store.validate_new(
45
+ proposal.kind,
46
+ proposal.title,
47
+ proposal.text,
48
+ "Imported from allowlisted structured session data.",
49
+ "global",
50
+ "",
51
+ proposal.render_target,
52
+ "run_html",
53
+ origin=origin,
54
+ origin_hash=hashlib.sha256(proposal.text.encode()).hexdigest(),
55
+ evidence_refs=[
56
+ EvidenceRef(
57
+ kind="run",
58
+ title="Structured run entry",
59
+ uri=f"run:{import_key}#{proposal.entry_id}",
60
+ timestamp=proposal.timestamp,
61
+ )
62
+ ],
63
+ )
64
+ ids: list[str] = []
65
+ for proposal in proposals:
66
+ origin = f"run:{import_key}:{proposal.entry_id}"
67
+ if origin in existing:
68
+ ids.append(existing[origin].id)
69
+ continue
70
+ memory = store.create(
71
+ proposal.kind,
72
+ proposal.title,
73
+ proposal.text,
74
+ "Imported from allowlisted structured session data.",
75
+ "global",
76
+ "",
77
+ proposal.render_target,
78
+ "run_html",
79
+ origin=origin,
80
+ origin_hash=hashlib.sha256(proposal.text.encode()).hexdigest(),
81
+ evidence_refs=[
82
+ EvidenceRef(
83
+ kind="run",
84
+ title="Structured run entry",
85
+ uri=f"run:{import_key}#{proposal.entry_id}",
86
+ timestamp=proposal.timestamp,
87
+ )
88
+ ],
89
+ )
90
+ ids.append(memory.id)
91
+ return ids
92
+ text = re.sub(r"<[^>]+>", " ", raw)
93
+ text = unescape(re.sub(r"\s+", " ", text)).strip()
94
+ excerpt = text[:2000]
95
+ memory = store.create(
96
+ "episode",
97
+ "Imported run memory",
98
+ excerpt,
99
+ f"Imported from {path.name}.",
100
+ "global",
101
+ "",
102
+ "linked_doc",
103
+ "run_html",
104
+ origin=f"run-legacy:{hashlib.sha256(raw.encode()).hexdigest()}",
105
+ origin_hash=hashlib.sha256(excerpt.encode()).hexdigest(),
106
+ )
107
+ import_id = store.next_import_id()
108
+ idir = store.config.storage_dir / "imports" / import_id
109
+ idir.mkdir(parents=True, exist_ok=True)
110
+ atomic_write_text(idir / "raw_excerpt.md", excerpt + "\n")
111
+ return [memory.id]
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import app
4
+
5
+
6
+ def main() -> None:
7
+ app()
memoryledger/models.py ADDED
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from pathlib import Path
5
+
6
+ KINDS = ("rule", "learning", "episode", "procedure", "semantic", "document", "local")
7
+ STATUSES = ("candidate", "accepted", "rejected", "archived")
8
+ SCOPES = ("global", "repo", "directory", "file", "command", "workflow", "local")
9
+ RENDER_TARGETS = ("root_agents", "linked_doc", "nested_agents", "none")
10
+ EVIDENCE_KINDS = (
11
+ "file",
12
+ "config",
13
+ "run",
14
+ "command",
15
+ "user_approval",
16
+ "external",
17
+ )
18
+ GENERATED_MARKER = "<!-- Generated by memoryledger. Do not edit directly. -->"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class EvidenceRef:
23
+ kind: str
24
+ title: str
25
+ uri: str
26
+ excerpt: str = ""
27
+ content_hash: str = ""
28
+ line_start: int | None = None
29
+ line_end: int | None = None
30
+ timestamp: str = ""
31
+
32
+ def to_dict(self) -> dict[str, object]:
33
+ return asdict(self)
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict[str, object]) -> EvidenceRef:
37
+ return cls(
38
+ kind=str(data["kind"]),
39
+ title=str(data["title"]),
40
+ uri=str(data["uri"]),
41
+ excerpt=str(data.get("excerpt", "")),
42
+ content_hash=str(data.get("content_hash", "")),
43
+ line_start=(
44
+ int(str(data["line_start"])) if data.get("line_start") else None
45
+ ),
46
+ line_end=int(str(data["line_end"])) if data.get("line_end") else None,
47
+ timestamp=str(data.get("timestamp", "")),
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Memory:
53
+ id: str
54
+ kind: str
55
+ title: str
56
+ status: str
57
+ priority: int
58
+ scope: str
59
+ scope_path: str
60
+ render_target: str
61
+ source: str
62
+ created_at: str
63
+ updated_at: str
64
+ version: int
65
+ tags: list[str] = field(default_factory=list)
66
+ origin: str = ""
67
+ origin_hash: str = ""
68
+ section: str = ""
69
+ evidence_refs: list[EvidenceRef] = field(default_factory=list)
70
+
71
+ def to_dict(self) -> dict[str, object]:
72
+ return asdict(self)
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: dict[str, object]) -> Memory:
76
+ raw_tags = data.get("tags", [])
77
+ tags = raw_tags if isinstance(raw_tags, list) else []
78
+ raw_refs = data.get("evidence_refs", [])
79
+ refs = raw_refs if isinstance(raw_refs, list) else []
80
+ return cls(
81
+ id=str(data["id"]),
82
+ kind=str(data["kind"]),
83
+ title=str(data["title"]),
84
+ status=str(data["status"]),
85
+ priority=int(str(data.get("priority", 100))),
86
+ scope=str(data.get("scope", "global")),
87
+ scope_path=str(data.get("scope_path", "")),
88
+ render_target=str(data.get("render_target", "root_agents")),
89
+ source=str(data.get("source", "cli")),
90
+ created_at=str(data["created_at"]),
91
+ updated_at=str(data["updated_at"]),
92
+ version=int(str(data.get("version", 1))),
93
+ tags=[str(tag) for tag in tags],
94
+ origin=str(data.get("origin", "")),
95
+ origin_hash=str(data.get("origin_hash", "")),
96
+ section=str(data.get("section", "")),
97
+ evidence_refs=[
98
+ EvidenceRef.from_dict(ref) for ref in refs if isinstance(ref, dict)
99
+ ],
100
+ )
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class RenderConfig:
105
+ root_agents_path: str = "AGENTS.md"
106
+ linked_docs_dir: str = "docs/agents"
107
+ nested_agents_enabled: bool = False
108
+ linked_docs_enabled: bool = True
109
+ max_root_agents_chars: int = 12000
110
+ max_linked_doc_chars: int = 50000
111
+ include_local: bool = False
112
+ include_rejected: bool = False
113
+ include_evidence: bool = False
114
+ evidence_index_path: str = ""
115
+ sort_order: list[str] = field(
116
+ default_factory=lambda: [
117
+ "rule",
118
+ "procedure",
119
+ "semantic",
120
+ "learning",
121
+ "episode",
122
+ "document",
123
+ ]
124
+ )
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Template:
129
+ id: str
130
+ version: str
131
+ kind: str
132
+ title: str
133
+ content: str = ""
134
+ content_file: str = ""
135
+ scope: str = "global"
136
+ scope_path: str = ""
137
+ render_target: str = "root_agents"
138
+ section: str = ""
139
+ source_root: Path = Path(".")
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class TemplatePolicy:
144
+ enabled: bool = False
145
+ ids: list[str] = field(default_factory=list)
146
+ auto_accept: bool = False
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class GlobalConfig:
151
+ path: Path
152
+ templates: list[Template] = field(default_factory=list)
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Config:
157
+ root: Path
158
+ config_path: Path
159
+ ledger_code: str = "ml"
160
+ ledger_name: str = "memoryledger"
161
+ project_name: str = "my-project"
162
+ project_uuid: str = ""
163
+ memoryledger_dir: str = ".memoryledger"
164
+ render: RenderConfig = field(default_factory=RenderConfig)
165
+ allow_run_html: bool = True
166
+ allow_current_run: bool = True
167
+ default_review_status: str = "candidate"
168
+ template_policy: TemplatePolicy = field(default_factory=TemplatePolicy)
169
+
170
+ @property
171
+ def storage_dir(self) -> Path:
172
+ return self.root / self.memoryledger_dir
memoryledger/py.typed ADDED
File without changes