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.
Files changed (71) hide show
  1. memcrumbs/__init__.py +6 -0
  2. memcrumbs/__main__.py +3 -0
  3. memcrumbs/bench.py +138 -0
  4. memcrumbs/cites.py +136 -0
  5. memcrumbs/claude_settings.py +148 -0
  6. memcrumbs/cli.py +300 -0
  7. memcrumbs/cli_args.py +140 -0
  8. memcrumbs/cmd_read.py +271 -0
  9. memcrumbs/cmd_setup.py +49 -0
  10. memcrumbs/cmd_write.py +114 -0
  11. memcrumbs/curate.py +120 -0
  12. memcrumbs/dashboard.py +204 -0
  13. memcrumbs/doctor.py +258 -0
  14. memcrumbs/dupes.py +85 -0
  15. memcrumbs/facts.py +256 -0
  16. memcrumbs/freshness.py +231 -0
  17. memcrumbs/guard.py +30 -0
  18. memcrumbs/header.py +182 -0
  19. memcrumbs/indexer.py +87 -0
  20. memcrumbs/jsonedit.py +87 -0
  21. memcrumbs/lint.py +161 -0
  22. memcrumbs/lint_records.py +57 -0
  23. memcrumbs/markdown.py +262 -0
  24. memcrumbs/outline/__init__.py +56 -0
  25. memcrumbs/outline/braces.py +33 -0
  26. memcrumbs/outline/md.py +21 -0
  27. memcrumbs/outline/mql.py +59 -0
  28. memcrumbs/outline/ps.py +44 -0
  29. memcrumbs/outline/py.py +69 -0
  30. memcrumbs/output.py +95 -0
  31. memcrumbs/packs/__init__.py +1 -0
  32. memcrumbs/plan.py +202 -0
  33. memcrumbs/plugins.py +105 -0
  34. memcrumbs/project.py +167 -0
  35. memcrumbs/query.py +112 -0
  36. memcrumbs/rank.py +152 -0
  37. memcrumbs/redact.py +226 -0
  38. memcrumbs/review.py +206 -0
  39. memcrumbs/sources.py +154 -0
  40. memcrumbs/store.py +359 -0
  41. memcrumbs/suggest.py +233 -0
  42. memcrumbs/suggest_gaps.py +222 -0
  43. memcrumbs/suggest_guard.py +405 -0
  44. memcrumbs/suggest_memory.py +176 -0
  45. memcrumbs/suggest_skill.py +339 -0
  46. memcrumbs/suggest_state.py +201 -0
  47. memcrumbs/suggest_wiki.py +123 -0
  48. memcrumbs/synonyms.py +104 -0
  49. memcrumbs/templates/agents_block.md +11 -0
  50. memcrumbs/templates/claude_block.md +11 -0
  51. memcrumbs/templates/dashboard.html +279 -0
  52. memcrumbs/templates/dashboard.js +708 -0
  53. memcrumbs/templates/gotchas.md +12 -0
  54. memcrumbs/templates/index.md +15 -0
  55. memcrumbs/templates/markdown.js +146 -0
  56. memcrumbs/templates/memcrumbs.toml +31 -0
  57. memcrumbs/templates/page.md +14 -0
  58. memcrumbs/templates/rules.md +12 -0
  59. memcrumbs/templates/skill.md +59 -0
  60. memcrumbs/transcripts.py +174 -0
  61. memcrumbs/usage.py +108 -0
  62. memcrumbs/util.py +178 -0
  63. memcrumbs/verdicts.py +79 -0
  64. memcrumbs/wire.py +191 -0
  65. memcrumbs/writeset.py +108 -0
  66. memcrumbs-0.2.0.dist-info/METADATA +153 -0
  67. memcrumbs-0.2.0.dist-info/RECORD +71 -0
  68. memcrumbs-0.2.0.dist-info/WHEEL +5 -0
  69. memcrumbs-0.2.0.dist-info/entry_points.txt +3 -0
  70. memcrumbs-0.2.0.dist-info/licenses/LICENSE +202 -0
  71. 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
@@ -0,0 +1,3 @@
1
+ from memcrumbs.cli import main
2
+
3
+ main()
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