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/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]
|
memoryledger/launcher.py
ADDED
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
|