memcrumbs 0.2.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.
- memcrumbs/__init__.py +6 -0
- memcrumbs/__main__.py +3 -0
- memcrumbs/bench.py +138 -0
- memcrumbs/cites.py +136 -0
- memcrumbs/claude_settings.py +148 -0
- memcrumbs/cli.py +300 -0
- memcrumbs/cli_args.py +140 -0
- memcrumbs/cmd_read.py +271 -0
- memcrumbs/cmd_setup.py +49 -0
- memcrumbs/cmd_write.py +114 -0
- memcrumbs/curate.py +120 -0
- memcrumbs/dashboard.py +204 -0
- memcrumbs/doctor.py +258 -0
- memcrumbs/dupes.py +85 -0
- memcrumbs/facts.py +256 -0
- memcrumbs/freshness.py +231 -0
- memcrumbs/guard.py +30 -0
- memcrumbs/header.py +182 -0
- memcrumbs/indexer.py +87 -0
- memcrumbs/jsonedit.py +87 -0
- memcrumbs/lint.py +161 -0
- memcrumbs/lint_records.py +57 -0
- memcrumbs/markdown.py +262 -0
- memcrumbs/outline/__init__.py +56 -0
- memcrumbs/outline/braces.py +33 -0
- memcrumbs/outline/md.py +21 -0
- memcrumbs/outline/mql.py +59 -0
- memcrumbs/outline/ps.py +44 -0
- memcrumbs/outline/py.py +69 -0
- memcrumbs/output.py +95 -0
- memcrumbs/packs/__init__.py +1 -0
- memcrumbs/plan.py +202 -0
- memcrumbs/plugins.py +105 -0
- memcrumbs/project.py +167 -0
- memcrumbs/query.py +112 -0
- memcrumbs/rank.py +152 -0
- memcrumbs/redact.py +226 -0
- memcrumbs/review.py +206 -0
- memcrumbs/sources.py +154 -0
- memcrumbs/store.py +359 -0
- memcrumbs/suggest.py +233 -0
- memcrumbs/suggest_gaps.py +222 -0
- memcrumbs/suggest_guard.py +405 -0
- memcrumbs/suggest_memory.py +176 -0
- memcrumbs/suggest_skill.py +339 -0
- memcrumbs/suggest_state.py +201 -0
- memcrumbs/suggest_wiki.py +123 -0
- memcrumbs/synonyms.py +104 -0
- memcrumbs/templates/agents_block.md +11 -0
- memcrumbs/templates/claude_block.md +11 -0
- memcrumbs/templates/dashboard.html +279 -0
- memcrumbs/templates/dashboard.js +708 -0
- memcrumbs/templates/gotchas.md +12 -0
- memcrumbs/templates/index.md +15 -0
- memcrumbs/templates/markdown.js +146 -0
- memcrumbs/templates/memcrumbs.toml +31 -0
- memcrumbs/templates/page.md +14 -0
- memcrumbs/templates/rules.md +12 -0
- memcrumbs/templates/skill.md +59 -0
- memcrumbs/transcripts.py +174 -0
- memcrumbs/usage.py +108 -0
- memcrumbs/util.py +178 -0
- memcrumbs/verdicts.py +79 -0
- memcrumbs/wire.py +191 -0
- memcrumbs/writeset.py +108 -0
- memcrumbs-0.2.0.dist-info/METADATA +153 -0
- memcrumbs-0.2.0.dist-info/RECORD +71 -0
- memcrumbs-0.2.0.dist-info/WHEEL +5 -0
- memcrumbs-0.2.0.dist-info/entry_points.txt +3 -0
- memcrumbs-0.2.0.dist-info/licenses/LICENSE +202 -0
- memcrumbs-0.2.0.dist-info/top_level.txt +1 -0
memcrumbs/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""memcrumbs - breadcrumbs of project memory for coding agents: a wiki plus a token-efficient index.
|
|
2
|
+
|
|
3
|
+
Layers: core (this package: wiki, index, outline, checks) -> packs (memcrumbs.packs.*: reusable domain add-ons)
|
|
4
|
+
-> project (<projects_dir>/<name>/: memcrumbs.toml, wiki/, plugins/ tailored to one project).
|
|
5
|
+
"""
|
|
6
|
+
__version__ = "0.2.0"
|
memcrumbs/__main__.py
ADDED
memcrumbs/bench.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""`memcrumbs bench QUESTIONS.toml`: recall@1 / recall@3 / MRR, tokens per answer and latency, against any
|
|
2
|
+
project (public or private) with no code changes.
|
|
3
|
+
|
|
4
|
+
[[q]]
|
|
5
|
+
id = "rb-01"
|
|
6
|
+
q = "how do I roll back a bad deploy?"
|
|
7
|
+
expect = ["wiki/runbooks/rollback.md"] # path suffixes; any one in the top k counts
|
|
8
|
+
category = "runbook" # optional; results are also broken down by category
|
|
9
|
+
|
|
10
|
+
A hit matches an expected path when its file path ends with it (`wiki/x.md`, `repo/app.py`); `path#Section`
|
|
11
|
+
also requires the hit's section to contain that text. Tokens are the `find` output's chars/4 (estimate)."""
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import tomllib
|
|
16
|
+
from contextlib import redirect_stdout
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from memcrumbs import cmd_read, writeset
|
|
20
|
+
from memcrumbs.output import Out, est_tokens
|
|
21
|
+
from memcrumbs.util import posix
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load(path):
|
|
25
|
+
with open(path, "rb") as f:
|
|
26
|
+
qs = tomllib.load(f).get("q", [])
|
|
27
|
+
for i, q in enumerate(qs):
|
|
28
|
+
if not q.get("q") or not q.get("expect"):
|
|
29
|
+
raise SystemExit("bench: question %d needs q and expect" % (i + 1))
|
|
30
|
+
q.setdefault("id", "q%d" % (i + 1))
|
|
31
|
+
return qs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _match(hit, expect):
|
|
35
|
+
path = "/" + posix(hit["path"]).lstrip("/")
|
|
36
|
+
for e in expect:
|
|
37
|
+
target, _, section = e.partition("#")
|
|
38
|
+
if path.lower().endswith("/" + target.strip("/").lower()) and \
|
|
39
|
+
(not section or section.lower() in (hit.get("section") or "").lower()):
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(project, store, questions, k=8):
|
|
45
|
+
rows = []
|
|
46
|
+
for q in questions:
|
|
47
|
+
t0 = time.perf_counter()
|
|
48
|
+
hits = store.search(q["q"], kinds=q.get("kinds"), limit=k)
|
|
49
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
50
|
+
buf = io.StringIO()
|
|
51
|
+
with redirect_stdout(buf): # the exact text an agent would read
|
|
52
|
+
out = Out("find", None, project.redact)
|
|
53
|
+
cmd_read.find(out, project, store, q["q"], q.get("kinds"), k, track=False)
|
|
54
|
+
out.close()
|
|
55
|
+
rank = next((i + 1 for i, h in enumerate(hits) if _match(h, q["expect"])), None)
|
|
56
|
+
followed = next((i + 1 for i, h in enumerate(hits) if _match(h, q["expect"]) or
|
|
57
|
+
(h.get("see") and _match(h["see"], q["expect"]))), None)
|
|
58
|
+
rows.append(dict(id=q["id"], q=q["q"], category=q.get("category", ""), paraphrase=bool(q.get("paraphrase")),
|
|
59
|
+
rank=rank, followed=followed, ms=round(ms, 1), tokens=est_tokens(len(buf.getvalue())),
|
|
60
|
+
top3=[project.rel(h["path"]) for h in hits[:3]]))
|
|
61
|
+
return rows
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _agg(rows):
|
|
65
|
+
n = len(rows) or 1
|
|
66
|
+
ms, toks = sorted(r["ms"] for r in rows), sorted(r["tokens"] for r in rows)
|
|
67
|
+
pct = (lambda xs, p: xs[min(len(xs) - 1, int(p * len(xs)))] if xs else 0)
|
|
68
|
+
return dict(n=len(rows), recall_at_1=round(sum(1 for r in rows if r["rank"] == 1) / n, 3),
|
|
69
|
+
recall_at_3=round(sum(1 for r in rows if r["rank"] and r["rank"] <= 3) / n, 3),
|
|
70
|
+
recall_at_3_with_pointers=round(sum(1 for r in rows if r.get("followed") and r["followed"] <= 3) / n, 3),
|
|
71
|
+
mrr=round(sum(1 / r["rank"] for r in rows if r["rank"]) / n, 3),
|
|
72
|
+
tokens_mean=round(sum(toks) / n), tokens_p95=pct(toks, 0.95),
|
|
73
|
+
ms_mean=round(sum(ms) / n, 1), ms_p95=pct(ms, 0.95))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_path(project):
|
|
77
|
+
"""The project's own bench, grown from real use with `memcrumbs bench --add` (kept with the wiki)."""
|
|
78
|
+
return project.wiki / "bench.toml"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def add(out, project, question, expects, category=""):
|
|
82
|
+
"""Record a question the wiki should answer, with the file(s) that do. Run it when a lookup missed."""
|
|
83
|
+
from memcrumbs.curate import exact_page
|
|
84
|
+
if not expects:
|
|
85
|
+
raise SystemExit("memcrumbs: --add needs --expect <page or file>")
|
|
86
|
+
targets = []
|
|
87
|
+
for e in expects:
|
|
88
|
+
try:
|
|
89
|
+
targets.append(project.rel(exact_page(project, e)))
|
|
90
|
+
except SystemExit:
|
|
91
|
+
p = Path(e).expanduser().resolve()
|
|
92
|
+
if not p.exists():
|
|
93
|
+
raise SystemExit("memcrumbs: no page or file %r" % e)
|
|
94
|
+
try:
|
|
95
|
+
targets.append(posix(p.relative_to(project.dir)))
|
|
96
|
+
except ValueError:
|
|
97
|
+
targets.append(posix(p))
|
|
98
|
+
path = default_path(project)
|
|
99
|
+
head = "" if path.exists() else ("# Questions this wiki must answer (memcrumbs bench). Added with "
|
|
100
|
+
"`memcrumbs bench --add`.\n")
|
|
101
|
+
n = len(load(path)) + 1 if path.exists() else 1
|
|
102
|
+
entry = '%s\n[[q]]\nid = "real-%03d"\nq = %s\nexpect = [%s]\n%s' % (
|
|
103
|
+
head, n, json.dumps(project.redact(question), ensure_ascii=False), ", ".join(json.dumps(t) for t in targets),
|
|
104
|
+
("category = %s\n" % json.dumps(category)) if category else "")
|
|
105
|
+
writeset.append_text(project, path, entry)
|
|
106
|
+
out.set(path=project.rel(path), id="real-%03d" % n, expect=targets)
|
|
107
|
+
out("added real-%03d to %s (%d questions) — memcrumbs bench to measure" % (n, project.rel(path), n))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def report(out, project, store, path, min_recall3=None, k=8):
|
|
111
|
+
path = path or default_path(project)
|
|
112
|
+
if not Path(path).exists():
|
|
113
|
+
raise SystemExit("memcrumbs: no questions at %s — record real ones with: memcrumbs bench --add \"<question>\" "
|
|
114
|
+
"--expect <page>" % posix(path))
|
|
115
|
+
rows = run(project, store, load(path), k)
|
|
116
|
+
total = _agg(rows)
|
|
117
|
+
cats = {c: _agg([r for r in rows if r["category"] == c]) for c in sorted({r["category"] for r in rows if r["category"]})}
|
|
118
|
+
para = _agg([r for r in rows if r["paraphrase"]])
|
|
119
|
+
misses = [r for r in rows if not r["rank"] or r["rank"] > 3]
|
|
120
|
+
out.list_key = "questions"
|
|
121
|
+
out.set(project=project.name, questions_file=posix(path), total=total, by_category=cats, paraphrase=para,
|
|
122
|
+
questions=rows)
|
|
123
|
+
out.hint("memcrumbs bench %s --json --full" % posix(path))
|
|
124
|
+
out("bench %s · %d questions · recall@1 %.3f · recall@3 %.3f · MRR %.3f" % (
|
|
125
|
+
project.name, total["n"], total["recall_at_1"], total["recall_at_3"], total["mrr"]))
|
|
126
|
+
out("recall@3 counting '→ see' pointers under a hit: %.3f" % total["recall_at_3_with_pointers"])
|
|
127
|
+
out("tokens/answer mean %d p95 %d (est.) · search latency mean %.1f ms p95 %.1f ms" % (
|
|
128
|
+
total["tokens_mean"], total["tokens_p95"], total["ms_mean"], total["ms_p95"]))
|
|
129
|
+
if para["n"]:
|
|
130
|
+
out("paraphrased questions: %d · recall@3 %.3f" % (para["n"], para["recall_at_3"]))
|
|
131
|
+
for c, a in cats.items():
|
|
132
|
+
out(" %-12s n=%-3d r@1 %.2f r@3 %.2f" % (c, a["n"], a["recall_at_1"], a["recall_at_3"]))
|
|
133
|
+
for r in misses:
|
|
134
|
+
out("miss %s (rank %s): %s → %s" % (r["id"], r["rank"] or "-", r["q"], ", ".join(r["top3"]) or "no hits"))
|
|
135
|
+
if min_recall3 is not None and total["recall_at_3"] < min_recall3:
|
|
136
|
+
out("FAIL: recall@3 %.3f < %.3f" % (total["recall_at_3"], min_recall3))
|
|
137
|
+
return 1
|
|
138
|
+
return 0
|
memcrumbs/cites.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Reverse lookup: what the wiki records about a file. At index time every markdown doc's `sources:` and
|
|
2
|
+
the file-like tokens in its text become citation rows; `about <path>` lists the pages, decision rows, incidents
|
|
3
|
+
and gotchas that cite a file, verdicts first, and code hits and `outline` say "cited by: …" in one line."""
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
EXT = ("py|pyw|ps1|psm1|psd1|js|mjs|cjs|ts|tsx|jsx|go|rs|cs|java|kt|rb|php|c|h|cpp|hpp|sql|sh|bash|bat|cmd|toml|json|"
|
|
8
|
+
"jsonc|yaml|yml|ini|cfg|conf|env|xml|csv|md|txt|mq5|mqh|mq4|service|timer|tf|kiln")
|
|
9
|
+
PATH = re.compile(r"(?<![\w/\\.:@~-])((?:[A-Za-z]:[/\\]|~?/)?(?:\.{1,2}[/\\])*+(?:[\w.~-]+[/\\])*+[\w-][\w.-]*\.(?:%s))"
|
|
10
|
+
r"(?![\w/\\-])" % EXT, re.I)
|
|
11
|
+
CITING = ("decision", "incident", "runbook", "wiki", "journal", "doc", "research", "memory", "legacy", "bug")
|
|
12
|
+
ORDER = {"decision": 0, "incident": 1, "runbook": 3, "wiki": 4, "journal": 6}
|
|
13
|
+
HEADER_PAGES = {"gotchas": "gotcha", "rules": "rule"}
|
|
14
|
+
NAMES = {"wiki": "page", "memory": "memory note", "legacy": "legacy note", "research": "research note",
|
|
15
|
+
"journal": "journal entry", "bug": "bug log entry", "doc": "doc"} # every group named
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def norm(token):
|
|
19
|
+
"""A cited path as stored: forward slashes, lower case, no leading ./ or ../ parts."""
|
|
20
|
+
t = token.replace("\\", "/").lower()
|
|
21
|
+
while t.startswith(("./", "../")):
|
|
22
|
+
t = t.split("/", 1)[1]
|
|
23
|
+
return t
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tokens(text):
|
|
27
|
+
return {norm(m) for m in PATH.findall(text or "")}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rows(path, docs):
|
|
31
|
+
"""Citation rows (target basename, token, page path, loc, kind, status, title, section) for one file's docs."""
|
|
32
|
+
out, seen = [], set()
|
|
33
|
+
for d in docs:
|
|
34
|
+
if d.kind not in CITING:
|
|
35
|
+
continue
|
|
36
|
+
for t in tokens(d.body) | tokens(getattr(d, "cites", "")) | tokens(d.section):
|
|
37
|
+
key = (t, d.loc)
|
|
38
|
+
if key not in seen:
|
|
39
|
+
seen.add(key)
|
|
40
|
+
out.append((t.rsplit("/", 1)[-1], t, path, d.loc, d.kind, (d.status or "").lower(), d.title, d.section))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _matches(store, project, path):
|
|
45
|
+
"""Rows citing `path`: a token with folders must be a suffix of the path; a bare name matches by name."""
|
|
46
|
+
p = Path(path)
|
|
47
|
+
rows = store.db.execute("SELECT token, path, loc, kind, status, title, section FROM cites WHERE target = ?",
|
|
48
|
+
(p.name.lower(),)).fetchall()
|
|
49
|
+
if not rows: # the usual case on a code hit: no filesystem calls at all
|
|
50
|
+
return []
|
|
51
|
+
if len(p.parts) == 1 and not p.exists(): # a bare file name: every citation of that name
|
|
52
|
+
return [dict(token=r[0], path=r[1], loc=r[2], kind=r[3], status=r[4], title=r[5], section=r[6], exact=True)
|
|
53
|
+
for r in rows]
|
|
54
|
+
forms = {p}
|
|
55
|
+
if p.exists() or p.is_absolute(): # as given and resolved: on macOS /var/… is /private/var/…
|
|
56
|
+
forms = {p.absolute(), p.resolve()}
|
|
57
|
+
rels = {f.as_posix().lower() for f in forms}
|
|
58
|
+
for root in list(getattr(project, "repos", [])) + [getattr(project, "dir", None)]:
|
|
59
|
+
if root is not None:
|
|
60
|
+
for f in forms:
|
|
61
|
+
for r in {Path(root), Path(root).resolve()}:
|
|
62
|
+
try:
|
|
63
|
+
rels.add(f.relative_to(r).as_posix().lower())
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
found = []
|
|
67
|
+
for r in rows:
|
|
68
|
+
token = r[0]
|
|
69
|
+
exact = "/" in token and any(x == token or x.endswith("/" + token) for x in rels)
|
|
70
|
+
if "/" in token and not exact:
|
|
71
|
+
continue
|
|
72
|
+
found.append(dict(token=token, path=r[1], loc=r[2], kind=r[3], status=r[4], title=r[5], section=r[6],
|
|
73
|
+
exact=exact))
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _kind(h):
|
|
78
|
+
stem = Path(h["path"]).stem.lower()
|
|
79
|
+
return HEADER_PAGES.get(stem, h["kind"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _bound_facts(project, path):
|
|
83
|
+
"""Facts whose value is read from this file ([facts.bind.KEY] from = …): the file is their source."""
|
|
84
|
+
binds = (getattr(project, "cfg", {}).get("facts", {}) or {}).get("bind") or {}
|
|
85
|
+
if not binds:
|
|
86
|
+
return []
|
|
87
|
+
from memcrumbs import facts, freshness
|
|
88
|
+
try:
|
|
89
|
+
me = Path(path).resolve()
|
|
90
|
+
except OSError:
|
|
91
|
+
return []
|
|
92
|
+
out = []
|
|
93
|
+
for key, spec in binds.items():
|
|
94
|
+
if any(p.resolve() == me for p in freshness.source_paths(project, [spec.get("from", "")])):
|
|
95
|
+
out.append(dict(token=Path(path).name.lower(), path=str(facts.page_path(project)), loc=1, kind="fact",
|
|
96
|
+
status="", title="fact %s (bound to this file)" % key, section="", exact=True, what="fact"))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def about(store, project, path):
|
|
101
|
+
"""One entry per citing page (its best location), verdicts and incidents first."""
|
|
102
|
+
best = {}
|
|
103
|
+
for h in _matches(store, project, path):
|
|
104
|
+
cur = best.get(h["path"])
|
|
105
|
+
if cur is None or (bool(h["status"]), h["exact"], -h["loc"]) > (bool(cur["status"]), cur["exact"], -cur["loc"]):
|
|
106
|
+
best[h["path"]] = h
|
|
107
|
+
hits = list(best.values()) + _bound_facts(project, path)
|
|
108
|
+
for h in hits:
|
|
109
|
+
h["what"] = h.get("what") or _kind(h)
|
|
110
|
+
hits.sort(key=lambda h: (0 if h["status"] in ("rejected", "accepted", "parked", "superseded") else 1,
|
|
111
|
+
{"gotcha": 2, "rule": 2}.get(h["what"], ORDER.get(h["kind"], 5)), not h["exact"], h["path"]))
|
|
112
|
+
return hits
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def summary(store, project, path):
|
|
116
|
+
"""'1 decision (REJECTED), 1 incident, 1 gotcha' or '' — one line for code hits and outline. Only what changes
|
|
117
|
+
how a file is handled; plain pages that mention it are left to `about`."""
|
|
118
|
+
return describe([h for h in about(store, project, path) if h["what"] in ("decision", "incident", "gotcha", "rule")])
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def describe(hits):
|
|
122
|
+
if not hits:
|
|
123
|
+
return ""
|
|
124
|
+
counts = {}
|
|
125
|
+
for h in hits:
|
|
126
|
+
counts.setdefault(h["what"], []).append(h["status"])
|
|
127
|
+
parts, named = [], {}
|
|
128
|
+
for what in sorted(counts, key=lambda w: {"decision": 0, "incident": 1, "gotcha": 2, "rule": 3, "fact": 4}.get(w, 5)):
|
|
129
|
+
name = NAMES.get(what, what.replace("_", " "))
|
|
130
|
+
named.setdefault(name, []).extend(counts[what])
|
|
131
|
+
for name, statuses in named.items():
|
|
132
|
+
st = sorted({s.upper() for s in statuses if s})
|
|
133
|
+
n = len(statuses)
|
|
134
|
+
plural = (name[:-1] + "ies" if name.endswith("y") else name + "s") if n > 1 else name
|
|
135
|
+
parts.append("%d %s%s" % (n, plural, " (%s)" % "/".join(st) if st else ""))
|
|
136
|
+
return ", ".join(parts)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""`.claude/settings.json` edits for `wire`: the SessionStart hook, and allow rules for the read-only lookups.
|
|
2
|
+
|
|
3
|
+
Each edit is one textual insertion right after an opening brace or bracket (jsonedit.py), so `--remove` takes
|
|
4
|
+
out exactly the inserted text and every other byte stays as it was. A hook written by memcrumbs 0.1 (whole file
|
|
5
|
+
re-serialized by json.dumps) is removed at the JSON level when the file is still in that exact format."""
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from memcrumbs import jsonedit
|
|
9
|
+
from memcrumbs.jsonedit import JsonEditError
|
|
10
|
+
from memcrumbs.util import newline_of
|
|
11
|
+
|
|
12
|
+
READ_ONLY = ("find", "page", "outline", "sym", "status", "about", "fact") # never log/decision/incident/verify/plan
|
|
13
|
+
SHELLS = ("Bash", "PowerShell") # Claude Code on Windows runs commands via PowerShell
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hook_command(project):
|
|
17
|
+
return "memcrumbs -p %s status --hook" % project.name # -p before the subcommand: valid for any argparse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hook_entry(project):
|
|
21
|
+
return {"matcher": "", "hooks": [{"type": "command", "command": hook_command(project), "timeout": 30}]}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def allow_rules(project):
|
|
25
|
+
"""Prefix rules for the read-only subcommands, with and without `-p NAME`, for both shell tools."""
|
|
26
|
+
return ["%s(memcrumbs %s%s:*)" % (sh, pre, cmd) for sh in SHELLS for pre in ("", "-p %s " % project.name)
|
|
27
|
+
for cmd in READ_ONLY]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ours(cmd):
|
|
31
|
+
return isinstance(cmd, str) and cmd.startswith("memcrumbs ") and " status" in cmd
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_hook(data):
|
|
35
|
+
hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {}
|
|
36
|
+
return any(ours(h.get("command")) for e in hooks.get("SessionStart") or [] if isinstance(e, dict)
|
|
37
|
+
for h in e.get("hooks") or [] if isinstance(h, dict))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def missing_allow(data, project):
|
|
41
|
+
perms = data.get("permissions") if isinstance(data.get("permissions"), dict) else {}
|
|
42
|
+
have = set(perms.get("allow") or [])
|
|
43
|
+
return [r for r in allow_rules(project) if r not in have]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _snippets(keys, entries):
|
|
47
|
+
k0, k1 = keys
|
|
48
|
+
for frag in ('"%s": {"%s": [%s]}' % (k0, k1, entries), '"%s": [%s]' % (k1, entries), entries):
|
|
49
|
+
for indent in (" ", " ", " "):
|
|
50
|
+
for nl in ("\r\n", "\n"): # CRLF first: an LF snippet also matches inside it
|
|
51
|
+
for tail in (",", nl):
|
|
52
|
+
yield nl + indent + frag + tail
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def insert(text, keys, entries):
|
|
56
|
+
"""Insert `entries` (JSON array items as text) into data[k0][k1], creating the object and list as needed."""
|
|
57
|
+
k0, k1 = keys
|
|
58
|
+
top = jsonedit._ws(text, 0)
|
|
59
|
+
m = jsonedit.members(text, top)
|
|
60
|
+
if k0 not in m:
|
|
61
|
+
at, frag, indent = top, '"%s": {"%s": [%s]}' % (k0, k1, entries), " "
|
|
62
|
+
elif text[m[k0][0]] == "{":
|
|
63
|
+
inner = jsonedit.members(text, m[k0][0])
|
|
64
|
+
if k1 not in inner:
|
|
65
|
+
at, frag, indent = m[k0][0], '"%s": [%s]' % (k1, entries), " "
|
|
66
|
+
elif text[inner[k1][0]] == "[":
|
|
67
|
+
at, frag, indent = inner[k1][0], entries, " "
|
|
68
|
+
else:
|
|
69
|
+
raise JsonEditError("%s.%s is not a list" % keys)
|
|
70
|
+
else:
|
|
71
|
+
raise JsonEditError("%s is not an object" % k0)
|
|
72
|
+
return jsonedit.insert_after_open(text, at, frag, indent, newline_of(text))[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def take_out(text, keys, entries):
|
|
76
|
+
"""Remove exactly what `insert` added; None if that text is not there (edited by hand)."""
|
|
77
|
+
for snip in _snippets(keys, entries):
|
|
78
|
+
if snip in text:
|
|
79
|
+
return text.replace(snip, "", 1)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def remove_items(text, keys, items):
|
|
84
|
+
"""Fallback when an editor reformatted the list: drop each string in `items` from data[k0][k1] by its element
|
|
85
|
+
span, keeping every other byte. None if the list is missing or holds none of them."""
|
|
86
|
+
k0, k1 = keys
|
|
87
|
+
m = jsonedit.members(text, jsonedit._ws(text, 0))
|
|
88
|
+
if k0 not in m or text[m[k0][0]] != "{":
|
|
89
|
+
return None
|
|
90
|
+
inner = jsonedit.members(text, m[k0][0])
|
|
91
|
+
if k1 not in inner or text[inner[k1][0]] != "[":
|
|
92
|
+
return None
|
|
93
|
+
start, end = inner[k1]
|
|
94
|
+
spans, i = [], jsonedit._ws(text, start + 1)
|
|
95
|
+
while text[i] != "]":
|
|
96
|
+
j = jsonedit.value_end(text, i)
|
|
97
|
+
spans.append((i, j))
|
|
98
|
+
i = jsonedit._ws(text, j)
|
|
99
|
+
if text[i] == ",":
|
|
100
|
+
i = jsonedit._ws(text, i + 1)
|
|
101
|
+
want = set(items)
|
|
102
|
+
drop = [k for k, (a, b) in enumerate(spans) if text[a] == '"' and json.loads(text[a:b]) in want]
|
|
103
|
+
if not drop:
|
|
104
|
+
return None
|
|
105
|
+
runs = []
|
|
106
|
+
for k in drop:
|
|
107
|
+
if runs and runs[-1][1] == k - 1:
|
|
108
|
+
runs[-1][1] = k
|
|
109
|
+
else:
|
|
110
|
+
runs.append([k, k])
|
|
111
|
+
for lo, hi in reversed(runs): # an element goes with the separator after it (before, if last)
|
|
112
|
+
if hi < len(spans) - 1:
|
|
113
|
+
text = text[:spans[lo][0]] + text[spans[hi + 1][0]:]
|
|
114
|
+
elif lo > 0:
|
|
115
|
+
text = text[:spans[lo - 1][1]] + text[spans[hi][1]:]
|
|
116
|
+
else:
|
|
117
|
+
text = text[:start + 1] + text[end - 1:]
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def hook_text(project):
|
|
122
|
+
return json.dumps(hook_entry(project))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def allow_text(rules):
|
|
126
|
+
return ", ".join(json.dumps(r) for r in rules)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def canonical_remove_hook(text, data):
|
|
130
|
+
"""settings.json written by json.dumps(indent=2) (memcrumbs 0.1 did this): drop our hook at the JSON level and
|
|
131
|
+
re-serialize in the same style, which reproduces every other byte. None if the file is hand-formatted."""
|
|
132
|
+
nl = newline_of(text)
|
|
133
|
+
body = text.replace("\r\n", "\n")
|
|
134
|
+
for ascii_ in (True, False):
|
|
135
|
+
canon = json.dumps(data, indent=2, ensure_ascii=ascii_)
|
|
136
|
+
if body.rstrip("\n") != canon or len(body) - len(canon) > 1:
|
|
137
|
+
continue
|
|
138
|
+
hooks = data["hooks"]
|
|
139
|
+
kept = [e for e in hooks["SessionStart"] if not (isinstance(e, dict) and any(
|
|
140
|
+
ours(h.get("command")) for h in e.get("hooks") or [] if isinstance(h, dict)))]
|
|
141
|
+
if kept:
|
|
142
|
+
hooks["SessionStart"] = kept
|
|
143
|
+
else:
|
|
144
|
+
del hooks["SessionStart"]
|
|
145
|
+
if not hooks:
|
|
146
|
+
del data["hooks"]
|
|
147
|
+
return (json.dumps(data, indent=2, ensure_ascii=ascii_) + body[len(canon):]).replace("\n", nl)
|
|
148
|
+
return None
|