lrdmem 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.
- lrdmem/__init__.py +3 -0
- lrdmem/__main__.py +5 -0
- lrdmem/checks.py +156 -0
- lrdmem/cli.py +747 -0
- lrdmem/commitmsg.py +59 -0
- lrdmem/config.py +65 -0
- lrdmem/consolidate.py +413 -0
- lrdmem/edges.py +107 -0
- lrdmem/evidence.py +175 -0
- lrdmem/experience.py +135 -0
- lrdmem/export.py +113 -0
- lrdmem/frontmatter.py +151 -0
- lrdmem/graph.py +160 -0
- lrdmem/hooks/__init__.py +269 -0
- lrdmem/hooks/claude.py +67 -0
- lrdmem/hooks/codex.py +22 -0
- lrdmem/hooks/opencode-plugin.js +59 -0
- lrdmem/hooks/opencode.py +40 -0
- lrdmem/install.py +181 -0
- lrdmem/learn.py +179 -0
- lrdmem/lint.py +349 -0
- lrdmem/model.py +56 -0
- lrdmem/pr.py +135 -0
- lrdmem/propose.py +321 -0
- lrdmem/recall.py +184 -0
- lrdmem/revision.py +127 -0
- lrdmem/search.py +173 -0
- lrdmem/session.py +60 -0
- lrdmem/skill/SKILL.md +145 -0
- lrdmem/stale.py +28 -0
- lrdmem/store.py +531 -0
- lrdmem/sync.py +277 -0
- lrdmem/triggers.py +52 -0
- lrdmem/write.py +296 -0
- lrdmem-0.1.0.dist-info/METADATA +737 -0
- lrdmem-0.1.0.dist-info/RECORD +40 -0
- lrdmem-0.1.0.dist-info/WHEEL +5 -0
- lrdmem-0.1.0.dist-info/entry_points.txt +2 -0
- lrdmem-0.1.0.dist-info/licenses/LICENSE +21 -0
- lrdmem-0.1.0.dist-info/top_level.txt +1 -0
lrdmem/__init__.py
ADDED
lrdmem/__main__.py
ADDED
lrdmem/checks.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Obligation checks: run `check.run` commands, but only the exact text a human approved on this device."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from lrdmem.store import Memory, Store
|
|
13
|
+
from lrdmem.triggers import matches
|
|
14
|
+
|
|
15
|
+
TIMEOUT = 300
|
|
16
|
+
TAIL = 60
|
|
17
|
+
KILL_WAIT = 5 # seconds to wait for output pipes after killing the group
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CheckResult:
|
|
22
|
+
"""Outcome of one run_checks call: failures are (slug, output tail), unrunnable are (slug, reason)."""
|
|
23
|
+
|
|
24
|
+
failures: list[tuple[str, str]] = field(default_factory=list)
|
|
25
|
+
skipped_pending: list[str] = field(default_factory=list)
|
|
26
|
+
ran: list[str] = field(default_factory=list)
|
|
27
|
+
unrunnable: list[tuple[str, str]] = field(default_factory=list)
|
|
28
|
+
outcomes: list[tuple[Memory, bool, str, list[str]]] = field(default_factory=list) # (memory, passed, tail, files)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def key(store: Store, memory: Memory) -> str:
|
|
32
|
+
"""Allowlist key: the store-relative path of the memory without .md."""
|
|
33
|
+
return memory.path.relative_to(store.root).with_suffix("").as_posix()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def command_text(memory: Memory) -> str | None:
|
|
37
|
+
"""The check.run text of an obligation, or None when it has none."""
|
|
38
|
+
c = memory.check
|
|
39
|
+
run = c.get("run") if c else None
|
|
40
|
+
return run if memory.kind == "obligation" and isinstance(run, str) and run.strip() else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def command_hash(memory: Memory) -> str:
|
|
44
|
+
"""sha256 hex of the memory's check.run exactly as written."""
|
|
45
|
+
return hashlib.sha256((command_text(memory) or "").encode("utf-8")).hexdigest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _script(cmd: str) -> str | None:
|
|
49
|
+
"""cmd with each `{files}` word replaced by `"$@"`, or None unless every `{files}` is a bare unquoted word."""
|
|
50
|
+
pieces: list[str] = []
|
|
51
|
+
tok, clean, quote, i = "", True, "", 0 # clean: the current word holds no quote or escape
|
|
52
|
+
|
|
53
|
+
def end_word() -> None:
|
|
54
|
+
nonlocal tok, clean
|
|
55
|
+
pieces.append('"$@"' if clean and tok == "{files}" else tok)
|
|
56
|
+
tok, clean = "", True
|
|
57
|
+
|
|
58
|
+
while i < len(cmd):
|
|
59
|
+
ch = cmd[i]
|
|
60
|
+
if ch == "\\" and quote != "'": # escape: keep it and the next char together
|
|
61
|
+
tok, clean, i = tok + cmd[i:i + 2], False, i + 1
|
|
62
|
+
elif quote:
|
|
63
|
+
tok += ch
|
|
64
|
+
quote = "" if ch == quote else quote
|
|
65
|
+
elif ch in "'\"":
|
|
66
|
+
tok, clean, quote = tok + ch, False, ch
|
|
67
|
+
elif ch.isspace():
|
|
68
|
+
end_word()
|
|
69
|
+
pieces.append(ch)
|
|
70
|
+
else:
|
|
71
|
+
tok += ch
|
|
72
|
+
i += 1
|
|
73
|
+
end_word()
|
|
74
|
+
script = "".join(pieces)
|
|
75
|
+
# an occurrence that did not become a bare word was quoted, escaped or glued to other text
|
|
76
|
+
return script if script.count('"$@"') - cmd.count('"$@"') == cmd.count("{files}") else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def problem(memory: Memory) -> str | None:
|
|
80
|
+
"""Why this memory's check.run can neither be approved nor run, or None when it is fine."""
|
|
81
|
+
cmd = command_text(memory)
|
|
82
|
+
if cmd is None:
|
|
83
|
+
return "no check.run"
|
|
84
|
+
if not all(0x20 <= ord(c) <= 0x7E for c in cmd):
|
|
85
|
+
return "unapprovable: non-printable or non-ASCII characters"
|
|
86
|
+
if "{files}" in cmd and any(t in cmd for t in ("$(", "`", "${")):
|
|
87
|
+
return "unrunnable: {files} cannot be combined with $(, ` or ${"
|
|
88
|
+
if _script(cmd) is None:
|
|
89
|
+
return "unrunnable: {files} must be an unquoted word"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _approved(store: Store, memory: Memory, cfg: dict) -> bool:
|
|
94
|
+
return cfg["allow"].get(key(store, memory)) == command_hash(memory)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pending(store: Store, cfg: dict) -> list[Memory]:
|
|
98
|
+
"""Obligations with a check.run whose hash is not the approved one."""
|
|
99
|
+
return [m for m in store.memories() if command_text(m) and not _approved(store, m, cfg)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _kill(proc: subprocess.Popen) -> None:
|
|
103
|
+
try:
|
|
104
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
105
|
+
except OSError:
|
|
106
|
+
proc.kill()
|
|
107
|
+
try:
|
|
108
|
+
proc.communicate(timeout=KILL_WAIT)
|
|
109
|
+
except subprocess.TimeoutExpired: # a grandchild in its own session still holds the pipe
|
|
110
|
+
proc.stdout.close()
|
|
111
|
+
proc.wait() # the shell itself got SIGKILL: reap it
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_checks(store: Store, env: str, repo: str, repo_root: Path, on: str, files: list[str], cfg: dict,
|
|
115
|
+
budget: float | None = None) -> CheckResult:
|
|
116
|
+
"""Run the approved obligations for `on` against the files their triggers match; unapproved ones are never run.
|
|
117
|
+
|
|
118
|
+
budget is the wall-clock seconds shared by every check of this call (default TIMEOUT); each check gets what
|
|
119
|
+
is left of it. File names are passed as argv to `sh -c`, never spliced into the command text.
|
|
120
|
+
"""
|
|
121
|
+
result = CheckResult()
|
|
122
|
+
deadline = time.monotonic() + (TIMEOUT if budget is None else budget)
|
|
123
|
+
for m in store.memories():
|
|
124
|
+
if not command_text(m) or m.check.get("on") != on or m.state != "active":
|
|
125
|
+
continue # only active obligations: candidates and contested ones are never enforced
|
|
126
|
+
hit = [f for f in files if matches(m, env, repo, path=f, env_wide=True)]
|
|
127
|
+
if not hit:
|
|
128
|
+
continue
|
|
129
|
+
why = problem(m)
|
|
130
|
+
if why:
|
|
131
|
+
result.unrunnable.append((m.slug, why))
|
|
132
|
+
continue
|
|
133
|
+
if not _approved(store, m, cfg):
|
|
134
|
+
result.skipped_pending.append(m.slug)
|
|
135
|
+
continue
|
|
136
|
+
left = deadline - time.monotonic()
|
|
137
|
+
if left <= 0:
|
|
138
|
+
result.failures.append((m.slug, "not run: the check time budget is used up"))
|
|
139
|
+
continue
|
|
140
|
+
argv = ["sh", "-c", _script(command_text(m)), "sh", *("./" + f if f.startswith("-") else f for f in hit)]
|
|
141
|
+
proc = subprocess.Popen(argv, cwd=repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
|
142
|
+
errors="replace", start_new_session=True)
|
|
143
|
+
try:
|
|
144
|
+
out, _ = proc.communicate(timeout=left)
|
|
145
|
+
except subprocess.TimeoutExpired:
|
|
146
|
+
_kill(proc)
|
|
147
|
+
result.failures.append((m.slug, f"timed out after {math.ceil(left)}s"))
|
|
148
|
+
result.outcomes.append((m, False, f"timed out after {math.ceil(left)}s", hit))
|
|
149
|
+
continue
|
|
150
|
+
tail = "\n".join(out.splitlines()[-TAIL:])
|
|
151
|
+
if proc.returncode:
|
|
152
|
+
result.failures.append((m.slug, tail))
|
|
153
|
+
else:
|
|
154
|
+
result.ran.append(m.slug)
|
|
155
|
+
result.outcomes.append((m, not proc.returncode, tail, hit))
|
|
156
|
+
return result
|