brain-wiki 0.1.0.dev0__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 (141) hide show
  1. brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
  2. brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
  3. brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
  5. brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  6. brainwiki/__init__.py +17 -0
  7. brainwiki/__main__.py +5 -0
  8. brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
  9. brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
  10. brainwiki/assets/plugin/.mcp.json +16 -0
  11. brainwiki/assets/plugin/mcp.json +17 -0
  12. brainwiki/assets/plugin/mcp_config.json +15 -0
  13. brainwiki/assets/plugin/plugin.json +37 -0
  14. brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
  15. brainwiki/assets/plugin/server.json +30 -0
  16. brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
  17. brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
  18. brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
  19. brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
  20. brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
  21. brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
  22. brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
  23. brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
  24. brainwiki/assets/prompts/consolidate.md +89 -0
  25. brainwiki/assets/prompts/distill.md +96 -0
  26. brainwiki/assets/prompts/harvest.md +142 -0
  27. brainwiki/bench/__init__.py +8 -0
  28. brainwiki/bench/_generate.py +440 -0
  29. brainwiki/bench/_report.py +166 -0
  30. brainwiki/bench/_suites.py +959 -0
  31. brainwiki/cli/__init__.py +7 -0
  32. brainwiki/cli/_cmd_bench.py +101 -0
  33. brainwiki/cli/_cmd_browse.py +281 -0
  34. brainwiki/cli/_cmd_consolidate.py +108 -0
  35. brainwiki/cli/_cmd_core.py +155 -0
  36. brainwiki/cli/_cmd_harvest.py +137 -0
  37. brainwiki/cli/_cmd_hosts.py +52 -0
  38. brainwiki/cli/_cmd_import.py +149 -0
  39. brainwiki/cli/_cmd_lint.py +117 -0
  40. brainwiki/cli/_cmd_read.py +131 -0
  41. brainwiki/cli/_cmd_runs.py +66 -0
  42. brainwiki/cli/_cmd_viz.py +57 -0
  43. brainwiki/cli/_cmd_write.py +122 -0
  44. brainwiki/cli/_main.py +511 -0
  45. brainwiki/cli/_output.py +19 -0
  46. brainwiki/config/__init__.py +56 -0
  47. brainwiki/config/_dirs.py +69 -0
  48. brainwiki/config/_manifest.py +307 -0
  49. brainwiki/config/_resolve.py +169 -0
  50. brainwiki/config/_user.py +183 -0
  51. brainwiki/consolidate/__init__.py +19 -0
  52. brainwiki/consolidate/_apply.py +408 -0
  53. brainwiki/consolidate/_packet.py +489 -0
  54. brainwiki/consolidate/_pipeline.py +215 -0
  55. brainwiki/consolidate/_presets.py +230 -0
  56. brainwiki/consolidate/_review.py +104 -0
  57. brainwiki/consolidate/_schema.py +153 -0
  58. brainwiki/consolidate/_validate.py +252 -0
  59. brainwiki/doctor.py +216 -0
  60. brainwiki/engine.py +80 -0
  61. brainwiki/errors.py +81 -0
  62. brainwiki/format/__init__.py +112 -0
  63. brainwiki/format/_agents_md.py +26 -0
  64. brainwiki/format/_claims.py +95 -0
  65. brainwiki/format/_document.py +203 -0
  66. brainwiki/format/_edit.py +94 -0
  67. brainwiki/format/_episode.py +67 -0
  68. brainwiki/format/_fields.py +350 -0
  69. brainwiki/format/_frontmatter.py +175 -0
  70. brainwiki/format/_links.py +72 -0
  71. brainwiki/format/_meta.py +67 -0
  72. brainwiki/format/_normalize.py +70 -0
  73. brainwiki/format/_relations.py +211 -0
  74. brainwiki/format/_rules.py +41 -0
  75. brainwiki/harvest.py +414 -0
  76. brainwiki/hosts/__init__.py +22 -0
  77. brainwiki/hosts/_antigravity.py +163 -0
  78. brainwiki/hosts/_claude_code.py +174 -0
  79. brainwiki/hosts/_codex.py +149 -0
  80. brainwiki/hosts/_connect.py +443 -0
  81. brainwiki/hosts/_detect.py +39 -0
  82. brainwiki/hosts/_instructions.py +220 -0
  83. brainwiki/hosts/_sessions.py +176 -0
  84. brainwiki/ids.py +69 -0
  85. brainwiki/importer/__init__.py +17 -0
  86. brainwiki/importer/_episodes.py +112 -0
  87. brainwiki/importer/_into.py +102 -0
  88. brainwiki/importer/_normalize.py +64 -0
  89. brainwiki/importer/_overlay.py +199 -0
  90. brainwiki/importer/_survey.py +167 -0
  91. brainwiki/index/__init__.py +44 -0
  92. brainwiki/index/_brains.py +119 -0
  93. brainwiki/index/_db.py +131 -0
  94. brainwiki/index/_guards.py +154 -0
  95. brainwiki/index/_neighbors.py +174 -0
  96. brainwiki/index/_orient.py +400 -0
  97. brainwiki/index/_read.py +285 -0
  98. brainwiki/index/_resolver.py +147 -0
  99. brainwiki/index/_scanner.py +665 -0
  100. brainwiki/index/_schema.py +164 -0
  101. brainwiki/index/_search.py +659 -0
  102. brainwiki/index/_topics.py +253 -0
  103. brainwiki/index/_written.py +29 -0
  104. brainwiki/lifecycle.py +158 -0
  105. brainwiki/lint/__init__.py +20 -0
  106. brainwiki/lint/_catalog.py +85 -0
  107. brainwiki/lint/_cross.py +649 -0
  108. brainwiki/lint/_files.py +483 -0
  109. brainwiki/lint/_fixes.py +87 -0
  110. brainwiki/lint/_hook.py +54 -0
  111. brainwiki/lint/_model.py +103 -0
  112. brainwiki/lint/_reports.py +242 -0
  113. brainwiki/lint/_runner.py +156 -0
  114. brainwiki/logs.py +34 -0
  115. brainwiki/mcp/__init__.py +7 -0
  116. brainwiki/mcp/_server.py +917 -0
  117. brainwiki/origin.py +97 -0
  118. brainwiki/runs/__init__.py +21 -0
  119. brainwiki/runs/_git.py +89 -0
  120. brainwiki/runs/_lock.py +42 -0
  121. brainwiki/runs/_record.py +145 -0
  122. brainwiki/runs/_transaction.py +441 -0
  123. brainwiki/runs/_undo.py +159 -0
  124. brainwiki/setup_wizard.py +143 -0
  125. brainwiki/store/__init__.py +7 -0
  126. brainwiki/store/_local.py +153 -0
  127. brainwiki/viz/__init__.py +9 -0
  128. brainwiki/viz/_export.py +414 -0
  129. brainwiki/viz/_lod.py +455 -0
  130. brainwiki/viz/_server.py +327 -0
  131. brainwiki/viz/app/BUILD_INFO.json +5 -0
  132. brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
  133. brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
  134. brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
  135. brainwiki/viz/app/index.html +14 -0
  136. brainwiki/write/__init__.py +23 -0
  137. brainwiki/write/_edits.py +730 -0
  138. brainwiki/write/_episode.py +195 -0
  139. brainwiki/write/_move.py +181 -0
  140. brainwiki/write/_rules.py +204 -0
  141. brainwiki/write/_secrets.py +108 -0
@@ -0,0 +1,195 @@
1
+ """`write_episode()`: the one function every episode writer goes through (design doc 05 §2.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING
10
+
11
+ from brainwiki.config import brain_state_dir
12
+ from brainwiki.errors import UsageError
13
+ from brainwiki.format import (
14
+ CAPTURED,
15
+ INPUTS,
16
+ ORIGINS,
17
+ EpisodeContent,
18
+ episode_path,
19
+ now_iso,
20
+ render_episode,
21
+ )
22
+ from brainwiki.ids import new_ulid
23
+ from brainwiki.index import written
24
+ from brainwiki.lifecycle import declare_format
25
+ from brainwiki.store import sha256_bytes
26
+ from brainwiki.write._secrets import scrub
27
+
28
+ if TYPE_CHECKING:
29
+ from brainwiki.engine import Engine
30
+
31
+ _MAX_EPISODE_BYTES = 64 * 1024
32
+ _TURNS = re.compile(r"^[0-9]+-[0-9]+$")
33
+ _SESSION = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") # host session ids; also glob-safe
34
+ _PLAIN = re.compile(r"^[^\r\n\x00]{1,200}$")
35
+
36
+
37
+ @dataclass
38
+ class EpisodeResult:
39
+ id: str
40
+ path: str
41
+ deduped: bool = False
42
+ redacted: list[str] = field(default_factory=list)
43
+ truncated: bool = False
44
+
45
+ def to_json(self) -> dict[str, object]:
46
+ return {
47
+ "id": self.id,
48
+ "path": self.path,
49
+ "deduped": self.deduped,
50
+ "redacted": self.redacted,
51
+ "truncated": self.truncated,
52
+ }
53
+
54
+
55
+ def _check(name: str, value: str | None, pattern: re.Pattern[str] = _PLAIN) -> None:
56
+ if value is not None and not pattern.match(value):
57
+ what = (
58
+ "letters, digits, and . _ : - (at most 200)"
59
+ if pattern is _SESSION
60
+ else "a single line of at most 200 characters"
61
+ )
62
+ raise UsageError(f"{name} must be {what}")
63
+
64
+
65
+ def _cap(text: str, limit: int) -> tuple[str, bool]:
66
+ data = text.encode("utf-8")
67
+ if len(data) <= limit:
68
+ return text, False
69
+ cut = data[:limit].decode("utf-8", errors="ignore")
70
+ cut = cut[: cut.rfind("\n")] if "\n" in cut[-2000:] else cut
71
+ return (
72
+ cut.rstrip() + f"\n\n[truncated: {len(data) - len(cut.encode('utf-8'))} bytes cut]\n",
73
+ True,
74
+ )
75
+
76
+
77
+ def _dedupe_marker(engine: Engine, key: str) -> str:
78
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
79
+ directory = brain_state_dir(engine.brain.key) / "dedupe"
80
+ directory.mkdir(parents=True, exist_ok=True)
81
+ return str(directory / digest)
82
+
83
+
84
+ def _existing(engine: Engine, key: str) -> tuple[str, str] | None:
85
+ """An episode already written with this dedupe key: (id, path)."""
86
+ marker = _dedupe_marker(engine, key)
87
+ try:
88
+ with open(marker, encoding="utf-8") as fh:
89
+ ulid, _, rel = fh.read().partition(" ")
90
+ if ulid and engine.store.exists(rel.strip()):
91
+ return ulid, rel.strip()
92
+ except OSError:
93
+ pass
94
+ engine.scan()
95
+ row = engine.db.execute(
96
+ """SELECT fm_id, path FROM nodes WHERE kind IN ('episode', 'archived_episode')
97
+ AND json_extract(props, '$.dedupe') = ? LIMIT 1""",
98
+ (key,),
99
+ ).fetchone()
100
+ return (row["fm_id"], row["path"]) if row else None
101
+
102
+
103
+ def _claim_marker(engine: Engine, key: str, ulid: str, rel: str) -> bool:
104
+ """Atomically claim a dedupe key on this device. False when another writer holds it."""
105
+ marker = _dedupe_marker(engine, key)
106
+ try:
107
+ fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
108
+ except FileExistsError:
109
+ return False
110
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
111
+ fh.write(f"{ulid} {rel}")
112
+ return True
113
+
114
+
115
+ def write_episode(
116
+ engine: Engine,
117
+ content: EpisodeContent,
118
+ *,
119
+ origin: str,
120
+ inputs: str,
121
+ captured: str,
122
+ agent: str | None = None,
123
+ session: str | None = None,
124
+ origin_turns: str | None = None,
125
+ device: str | None = None,
126
+ topics: list[str] | None = None,
127
+ dedupe: str | None = None,
128
+ source: str | None = None,
129
+ ) -> EpisodeResult:
130
+ """Write one immutable episode. Provenance comes from the caller's code path, never from text."""
131
+ if origin not in ORIGINS or inputs not in INPUTS or captured not in CAPTURED:
132
+ raise UsageError("invalid provenance values")
133
+ for name, value in (
134
+ ("agent", agent),
135
+ ("device", device),
136
+ ("dedupe", dedupe),
137
+ ):
138
+ _check(name, value)
139
+ _check("session", session, _SESSION)
140
+ _check("origin_turns", origin_turns, _TURNS)
141
+ if not (content.summary.strip() or content.claims):
142
+ raise UsageError("an episode needs a summary or at least one claim")
143
+ manifest = engine.manifest
144
+ if dedupe:
145
+ found = _existing(engine, dedupe)
146
+ if found:
147
+ return EpisodeResult(found[0], found[1], deduped=True)
148
+
149
+ hints = [t.strip() for t in topics or [] if t and t.strip()][:5]
150
+
151
+ redacted: list[str] = []
152
+
153
+ def clean(text: str) -> str:
154
+ cleaned, kinds = scrub(text)
155
+ redacted.extend(kinds)
156
+ return cleaned
157
+
158
+ scrubbed = EpisodeContent(
159
+ summary=clean(content.summary),
160
+ claims=[clean(c) for c in content.claims],
161
+ negative_findings=[clean(c) for c in content.negative_findings],
162
+ open_questions=[clean(c) for c in content.open_questions],
163
+ )
164
+ ulid = new_ulid()
165
+ rel = episode_path(manifest.layout.episodes_dir, ulid)
166
+ fields = {
167
+ "id": ulid,
168
+ "type": "episode",
169
+ "agent": agent,
170
+ "session": session,
171
+ "origin_turns": origin_turns,
172
+ "device": device or engine.user.device_name,
173
+ "origin": origin,
174
+ "inputs": inputs,
175
+ "captured": captured,
176
+ "created": now_iso(),
177
+ "topics": hints,
178
+ "dedupe": dedupe,
179
+ "source": source,
180
+ }
181
+ rendered = render_episode(fields, scrubbed)
182
+ head, sep, body = rendered.partition("\n---\n")
183
+ body, truncated = _cap(body, _MAX_EPISODE_BYTES - len(head) - len(sep))
184
+ text = head + sep + body
185
+ if dedupe and not _claim_marker(engine, dedupe, ulid, rel):
186
+ found = _existing(engine, dedupe)
187
+ if found:
188
+ return EpisodeResult(found[0], found[1], deduped=True)
189
+ if engine.store.exists(rel):
190
+ raise UsageError(f"{rel} already exists")
191
+ declare_format(engine)
192
+ engine.store.write_text(rel, text)
193
+ written.mark(engine.brain.key, ulid, sha256_bytes(text.encode("utf-8")))
194
+ engine.scan(paths=[rel])
195
+ return EpisodeResult(ulid, rel, redacted=sorted(set(redacted)), truncated=truncated)
@@ -0,0 +1,181 @@
1
+ """`brain mv`: move a note, or a topic folder with its notes, and rewrite the links that break.
2
+
3
+ Links by file name keep working when only folders change; links that name a path, and relative
4
+ Markdown links into or out of what moved, are rewritten. Topics are known by their slug, so
5
+ moving a topic folder re-parents it without breaking topic policies (design doc 02 §2).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import posixpath
13
+ import re
14
+ from typing import TYPE_CHECKING
15
+ from urllib.parse import quote
16
+
17
+ from brainwiki.errors import Conflict, NotFound, UsageError
18
+ from brainwiki.index import norm_key
19
+ from brainwiki.runs import Check, Run, RunRecord
20
+
21
+ if TYPE_CHECKING:
22
+ from brainwiki.engine import Engine
23
+
24
+
25
+ def _stem(path: str) -> str:
26
+ name = path.rsplit("/", 1)[-1]
27
+ return name[:-3] if name.lower().endswith(".md") else name
28
+
29
+
30
+ def _bare(path: str) -> str:
31
+ return path[:-3] if path.lower().endswith(".md") else path
32
+
33
+
34
+ def plan_move(engine: Engine, src: str, dst: str) -> Run:
35
+ engine.scan(force=True)
36
+ src, dst = src.rstrip("/"), dst.rstrip("/")
37
+ layout = engine.manifest.layout
38
+ managed = (layout.inbox_dir, layout.archive_dir, layout.reports_dir)
39
+ if any(p == m or p.startswith(m + "/") for p in (src, dst) for m in managed):
40
+ raise UsageError(
41
+ "mv moves notes, hubs, and topic folders; inbox, archive, and reports are managed by brain-wiki"
42
+ )
43
+ if engine.store.abspath(src).is_dir():
44
+ return _plan_folder(engine, src, dst)
45
+ if not dst.lower().endswith(".md"):
46
+ raise UsageError("the destination must be a .md path (or a folder, to move a topic)")
47
+ node = engine.db.execute("SELECT id, kind FROM nodes WHERE path=?", (src,)).fetchone()
48
+ if node is None or node["kind"] not in ("note", "hub"):
49
+ raise NotFound(f"{src} is not a note in this brain")
50
+ if engine.store.exists(dst):
51
+ raise Conflict(f"{dst} already exists")
52
+ engine.store.abspath(dst) # refuses paths outside the brain
53
+ run = Run(engine, "mv", summary=f"mv {src} → {dst}")
54
+ run.move(src, dst)
55
+ _rewrite_links(engine, run, {src: dst})
56
+ return run
57
+
58
+
59
+ def _plan_folder(engine: Engine, src: str, dst: str) -> Run:
60
+ """Move every file under `src` to the same place under `dst`: a topic and what it holds."""
61
+ if dst == src or dst.startswith(src + "/"):
62
+ raise UsageError(f"cannot move {src} into itself")
63
+ if engine.store.abspath(dst).exists(): # abspath also refuses paths outside the brain
64
+ raise Conflict(f"{dst} already exists")
65
+ root = engine.store.abspath(src)
66
+ files = sorted(
67
+ posixpath.join(src, os.path.relpath(os.path.join(folder, name), root).replace(os.sep, "/"))
68
+ for folder, _, names in os.walk(root)
69
+ for name in names
70
+ )
71
+ if not files:
72
+ raise NotFound(f"{src} holds no files")
73
+ run = Run(engine, "mv", summary=f"mv {src}/ → {dst}/")
74
+ mapping = {path: dst + path[len(src) :] for path in files}
75
+ for old, new in mapping.items():
76
+ run.move(old, new)
77
+ _rewrite_links(engine, run, mapping)
78
+ return run
79
+
80
+
81
+ def _rewrite_links(engine: Engine, run: Run, mapping: dict[str, str]) -> None:
82
+ """Rewrite links that named a moved note by path (or by a file name that changed), and
83
+ relative Markdown links whose two ends no longer sit where they did."""
84
+ conn = engine.db
85
+ moved: dict[str, tuple[str, str]] = {} # node id -> (old path, new path)
86
+ for chunk in _chunks(list(mapping)):
87
+ for row in conn.execute(
88
+ f"SELECT id, path FROM nodes WHERE kind IN ('note', 'hub') AND path IN ({','.join('?' * len(chunk))})", # noqa: S608 - placeholders only
89
+ chunk,
90
+ ):
91
+ moved[row["id"]] = (row["path"], mapping[row["path"]])
92
+ if not moved:
93
+ return
94
+ link_text: dict[str, tuple[str, bool]] = {} # node id -> (new link, links by name still work)
95
+ for node_id, (old, new) in moved.items():
96
+ others = conn.execute(
97
+ "SELECT COUNT(*) FROM names WHERE kind='stem' AND key=? AND node_id != ?",
98
+ (norm_key(_stem(new)), node_id),
99
+ ).fetchone()[0]
100
+ link_text[node_id] = (
101
+ _stem(new) if others == 0 else _bare(new),
102
+ _stem(old) == _stem(new) and others == 0,
103
+ )
104
+
105
+ # (line, raw target, written form, new wikilink text or None, new target path for md links)
106
+ edits: dict[str, list[tuple[int, str, str, str | None, str | None]]] = {}
107
+ for chunk in _chunks(list(moved)):
108
+ marks = ",".join("?" * len(chunk))
109
+ for edge in conn.execute( # links into what moved
110
+ f"""SELECT e.line, e.raw_target, e.written, e.other, n.path FROM edges e
111
+ JOIN nodes n ON n.id = e.declared_in
112
+ WHERE e.other IN ({marks}) AND e.declared_in != e.other AND e.line > 0""", # noqa: S608
113
+ chunk,
114
+ ):
115
+ old, new = moved[edge["other"]]
116
+ key = norm_key(edge["raw_target"])
117
+ by_path = key == norm_key(old) or ("/" in key and norm_key(old).endswith("/" + key))
118
+ by_stem = key == norm_key(_stem(old))
119
+ link, name_ok = link_text[edge["other"]]
120
+ wiki = link if by_path or (by_stem and not name_ok) else None
121
+ edits.setdefault(edge["path"], []).append(
122
+ (edge["line"], edge["raw_target"], edge["written"], wiki, new)
123
+ )
124
+ for edge in conn.execute( # relative Markdown links out of what moved
125
+ f"""SELECT e.line, e.raw_target, e.other, t.path AS target, n.path FROM edges e
126
+ JOIN nodes n ON n.id = e.declared_in JOIN nodes t ON t.id = e.other
127
+ WHERE e.declared_in IN ({marks}) AND e.written = 'md' AND e.line > 0""", # noqa: S608
128
+ chunk,
129
+ ):
130
+ if edge["other"] not in moved:
131
+ edits.setdefault(edge["path"], []).append(
132
+ (edge["line"], edge["raw_target"], "md", None, edge["target"])
133
+ )
134
+
135
+ for path, items in edits.items():
136
+ where = mapping.get(path, path) # a file that moved is edited at its new place
137
+ text = run.read(where)
138
+ if text is None:
139
+ continue
140
+ newline = "\r\n" if "\r\n" in text else "\n"
141
+ lines = text.split(newline)
142
+ for line_no, raw, written, wiki, target in items:
143
+ if line_no - 1 >= len(lines):
144
+ continue
145
+ line = lines[line_no - 1]
146
+ if wiki is not None:
147
+ line = re.sub(
148
+ r"\[\[\s*" + re.escape(raw) + r"(?=\s*(?:\||#|\]\]))", "[[" + wiki, line
149
+ )
150
+ if target is not None and (written == "md" or "](" in line):
151
+ href_new = posixpath.relpath(target, posixpath.dirname(where) or ".")
152
+ for href in {raw, quote(raw)}:
153
+ line = re.sub(
154
+ r"\]\(" + re.escape(href) + r"(#[^)]*)?\)",
155
+ lambda m, h=href_new: "](" + quote(h) + (m.group(1) or "") + ")",
156
+ line,
157
+ )
158
+ lines[line_no - 1] = line
159
+ joined = newline.join(lines)
160
+ if joined != text:
161
+ run.write(where, joined)
162
+
163
+
164
+ def _chunks(items: list[str], size: int = 500) -> list[list[str]]:
165
+ return [items[i : i + size] for i in range(0, len(items), size)]
166
+
167
+
168
+ def move_note(engine: Engine, src: str, dst: str, *, check: Check | None = None) -> RunRecord:
169
+ run = plan_move(engine, src, dst)
170
+ rewritten = [e.path for e in run.entries if e.moved_to is None]
171
+ moved = sum(1 for e in run.entries if e.moved_to is not None)
172
+ head = f"- {src} → {dst}" + (f" ({moved} files)" if moved > 1 else "")
173
+ record = run.commit(
174
+ check=check, log_lines=[head] + [f"- rewrote links in {p}" for p in rewritten]
175
+ )
176
+ root = engine.store.abspath(src.rstrip("/"))
177
+ if root.is_dir(): # a folder move leaves its emptied directories behind
178
+ for folder, _, _ in os.walk(root, topdown=False):
179
+ with contextlib.suppress(OSError): # not empty: something besides notes lives there
180
+ os.rmdir(folder)
181
+ return record
@@ -0,0 +1,204 @@
1
+ """Rules: the strictest write path (design doc 05 §6). Consolidation never calls this module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from brainwiki.errors import BudgetExceeded, NotFound, UsageError
9
+ from brainwiki.format import (
10
+ RULES_HEADER,
11
+ format_rule,
12
+ format_value,
13
+ now_iso,
14
+ parse_rules,
15
+ split,
16
+ split_meta,
17
+ )
18
+ from brainwiki.ids import is_prefixed, rule_id
19
+ from brainwiki.runs import Run, RunRecord
20
+
21
+ if TYPE_CHECKING:
22
+ from brainwiki.engine import Engine
23
+
24
+ _RULES = "rules.md"
25
+ _MAX_RULE_CHARS = 300
26
+ _MAX_QUOTE_CHARS = 500
27
+
28
+
29
+ def list_rules(engine: Engine) -> list[dict[str, Any]]:
30
+ try:
31
+ text = engine.store.read_text(_RULES)
32
+ except OSError:
33
+ return []
34
+ rules, _ = parse_rules(text)
35
+ return [
36
+ {
37
+ "id": r.id,
38
+ "text": r.text,
39
+ "quote": r.meta.get("quote"),
40
+ "at": r.meta.get("at"),
41
+ "origin": r.meta.get("origin"),
42
+ "via": r.meta.get("via"),
43
+ "line": r.line,
44
+ }
45
+ for r in rules
46
+ ]
47
+
48
+
49
+ def _validate(text: str, quote: str) -> tuple[str, str]:
50
+ text, quote = " ".join(text.split()), " ".join(quote.split())
51
+ if not text or len(text) > _MAX_RULE_CHARS:
52
+ raise UsageError(f"a rule must be 1 to {_MAX_RULE_CHARS} characters")
53
+ if not quote or len(quote) > _MAX_QUOTE_CHARS:
54
+ raise UsageError(f"the quote must be 1 to {_MAX_QUOTE_CHARS} characters")
55
+ return text, quote
56
+
57
+
58
+ def _stage_rule(run: Run, text: str, quote: str, replaces: str | None, via: str | None) -> str:
59
+ engine = run.engine
60
+ current = run.read(_RULES) or RULES_HEADER
61
+ new_id = rule_id()
62
+ meta = {
63
+ "id": new_id,
64
+ "quote": quote,
65
+ "at": datetime.now(UTC).strftime("%Y-%m-%d"),
66
+ "origin": "owner",
67
+ }
68
+ if via:
69
+ meta["via"] = via
70
+ lines = current.split("\n")
71
+ if replaces:
72
+ index = next(
73
+ (
74
+ i
75
+ for i, line in enumerate(lines)
76
+ if f"id:{replaces}" in line and line.lstrip().startswith("-")
77
+ ),
78
+ None,
79
+ )
80
+ if index is None:
81
+ raise NotFound(f"no rule {replaces} in rules.md")
82
+ old_line = lines.pop(index)
83
+ body, old_meta = split_meta(old_line.lstrip()[1:].strip())
84
+ old_meta = dict(old_meta or {})
85
+ old_meta.update(status="superseded", by=new_id)
86
+ archive = f"{engine.manifest.layout.archive_dir}/rules.md"
87
+ archived = run.read(archive) or "# Archived rules\n"
88
+ run.write(archive, archived.rstrip("\n") + "\n" + format_rule(body, old_meta) + "\n")
89
+ while lines and lines[-1] == "":
90
+ lines.pop()
91
+ lines.append(format_rule(text, meta))
92
+ updated = "\n".join(lines) + "\n"
93
+ budgets = engine.manifest.budgets
94
+ if (
95
+ len(updated.splitlines()) > budgets.rules_max_lines
96
+ or len(updated) > budgets.rules_max_chars
97
+ ):
98
+ longest = sorted(parse_rules(updated)[0], key=lambda r: len(r.text), reverse=True)[:3]
99
+ raise BudgetExceeded(
100
+ f"rules.md would exceed its budget ({budgets.rules_max_lines} lines, "
101
+ f"{budgets.rules_max_chars} characters); longest rules: "
102
+ + "; ".join(f"{r.id}: {r.text[:60]}" for r in longest)
103
+ )
104
+ rules, malformed = parse_rules(updated)
105
+ if malformed or not any(r.id == new_id for r in rules):
106
+ raise UsageError("the rule could not be written in a parseable form")
107
+ run.write(_RULES, updated)
108
+ return new_id
109
+
110
+
111
+ def add_rule(
112
+ engine: Engine,
113
+ text: str,
114
+ *,
115
+ quote: str | None = None,
116
+ replaces: str | None = None,
117
+ via: str | None = None,
118
+ agent: str | None = None,
119
+ ) -> tuple[str, RunRecord]:
120
+ """Add (or replace) a rule as the user. Callers are responsible for user confirmation."""
121
+ text, quote = _validate(text, quote or text)
122
+ if replaces and not is_prefixed(replaces, "r-"):
123
+ raise UsageError("replaces must be a rule id (r-…)")
124
+ run = Run(engine, "rule", agent=agent, summary=f"rule: {text[:60]}")
125
+ new_id = _stage_rule(run, text, quote, replaces, via)
126
+ log = [f"- added {new_id}: {text}"] + ([f"- replaced {replaces}"] if replaces else [])
127
+ return new_id, run.commit(log_lines=log)
128
+
129
+
130
+ # -- proposals: rules awaiting the user's confirmation --------------------
131
+ def _proposal_path(engine: Engine, proposal_id: str) -> str:
132
+ return f"{engine.manifest.layout.proposals_dir}/rule-{proposal_id}.md"
133
+
134
+
135
+ def propose_rule(
136
+ engine: Engine, text: str, quote: str, *, replaces: str | None = None, agent: str | None = None
137
+ ) -> tuple[str, str]:
138
+ """Write a rule proposal for `brain rule accept`. Returns (proposal id, path)."""
139
+ text, quote = _validate(text, quote)
140
+ proposal_id = rule_id()
141
+ rel = _proposal_path(engine, proposal_id)
142
+ fields = {
143
+ "id": proposal_id,
144
+ "type": "rule-proposal",
145
+ "agent": agent,
146
+ "created": now_iso(),
147
+ "replaces": replaces,
148
+ "quote": quote,
149
+ }
150
+ head = ["---"] + [f"{k}: {format_value(v)}" for k, v in fields.items() if v] + ["---"]
151
+ body = [
152
+ f"# Proposed rule: {text}",
153
+ "",
154
+ f"> {quote}",
155
+ "",
156
+ f"Accept with `brain rule accept {proposal_id}`, or reject with `brain rule reject {proposal_id}`.",
157
+ "",
158
+ ]
159
+ engine.store.write_text(rel, "\n".join(head + body))
160
+ return proposal_id, rel
161
+
162
+
163
+ def pending_rule_proposals(engine: Engine) -> list[dict[str, Any]]:
164
+ directory = engine.store.abspath(engine.manifest.layout.proposals_dir)
165
+ out = []
166
+ for path in sorted(directory.glob("rule-*.md")) if directory.is_dir() else []:
167
+ fm = split(path.read_text(encoding="utf-8"))
168
+ title = next((ln for ln in fm.body.splitlines() if ln.startswith("# Proposed rule: ")), "")
169
+ out.append(
170
+ {
171
+ "id": fm.data.get("id") or path.stem[len("rule-") :],
172
+ "text": title[len("# Proposed rule: ") :],
173
+ "quote": fm.data.get("quote"),
174
+ "agent": fm.data.get("agent"),
175
+ "replaces": fm.data.get("replaces"),
176
+ }
177
+ )
178
+ return out
179
+
180
+
181
+ def _proposal(engine: Engine, proposal_id: str) -> dict[str, Any]:
182
+ for proposal in pending_rule_proposals(engine):
183
+ if proposal["id"] == proposal_id:
184
+ return proposal
185
+ raise NotFound(f"no rule proposal {proposal_id}")
186
+
187
+
188
+ def accept_rule(engine: Engine, proposal_id: str) -> tuple[str, RunRecord]:
189
+ proposal = _proposal(engine, proposal_id)
190
+ text, quote = _validate(proposal["text"], str(proposal["quote"] or proposal["text"]))
191
+ run = Run(engine, "rule", summary=f"rule: {text[:60]}")
192
+ new_id = _stage_rule(run, text, quote, proposal.get("replaces"), "proposal")
193
+ run.delete(_proposal_path(engine, proposal_id))
194
+ log = [
195
+ f"- accepted proposal {proposal_id} from {proposal.get('agent') or 'an agent'} as {new_id}: {text}"
196
+ ]
197
+ return new_id, run.commit(log_lines=log)
198
+
199
+
200
+ def reject_rule(engine: Engine, proposal_id: str) -> RunRecord:
201
+ _proposal(engine, proposal_id)
202
+ run = Run(engine, "rule-reject", summary=f"rejected rule proposal {proposal_id}")
203
+ run.delete(_proposal_path(engine, proposal_id))
204
+ return run.commit()
@@ -0,0 +1,108 @@
1
+ """Secret detection and redaction (design doc 10 §6). Pattern-based; misses unusual formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+
10
+ # (name, pattern). Fixed-prefix formats first; the generic assignment rule runs last.
11
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
12
+ (
13
+ "private-key",
14
+ re.compile(
15
+ r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----|\Z)"
16
+ ),
17
+ ),
18
+ ("aws-access-key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
19
+ ("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
20
+ (
21
+ "github-token",
22
+ re.compile(r"\b(?:gh[pousr]_[0-9A-Za-z]{36,}|github_pat_[0-9A-Za-z_]{40,})\b"),
23
+ ),
24
+ ("slack-token", re.compile(r"\bxox[abposr]-[0-9A-Za-z-]{10,}\b")),
25
+ ("slack-webhook", re.compile(r"https://hooks\.slack\.com/services/[A-Za-z0-9/]{20,}")),
26
+ ("stripe-key", re.compile(r"\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}\b")),
27
+ ("anthropic-key", re.compile(r"\bsk-ant-[0-9A-Za-z_\-]{20,}\b")),
28
+ ("openai-key", re.compile(r"\bsk-(?:proj-|svcacct-|admin-)?[0-9A-Za-z_\-]{20,}\b")),
29
+ ("jwt", re.compile(r"\beyJ[0-9A-Za-z_\-]{8,}\.eyJ[0-9A-Za-z_\-]{8,}\.[0-9A-Za-z_\-]{8,}\b")),
30
+ ]
31
+ _ASSIGNMENT = re.compile(
32
+ r"(?i)\b(?P<key>[a-z0-9_.\-]*(?:password|passwd|secret|token|api[_\-]?key|access[_\-]?key)[a-z0-9_.\-]*)"
33
+ r"(?P<sep>\s*[:=]\s*|\s+is\s+)"
34
+ r"(?P<quote>[\"']?)(?P<value>[^\s\"'`,;]{12,})(?P=quote)"
35
+ )
36
+ _REDACTED = re.compile(r"\[REDACTED:[a-z-]+\]")
37
+ # Values that name or compute a secret rather than hold one: code (calls, templates, optional
38
+ # chaining), environment lookups, dotted references, ALL_CAPS variable names, and secret-manager
39
+ # references such as `github-pat:latest`.
40
+ _REFERENCE = re.compile(
41
+ r"[()${}]|\?\.|^(?:process\.env|os\.environ|env)\b|^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$"
42
+ r"|^[A-Z][A-Z0-9_]*$|:latest$"
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class SecretHit:
48
+ kind: str
49
+ start: int
50
+ end: int
51
+ line: int
52
+
53
+
54
+ def _entropy(value: str) -> float:
55
+ counts = Counter(value)
56
+ return -sum(c / len(value) * math.log2(c / len(value)) for c in counts.values())
57
+
58
+
59
+ def _high_entropy(value: str) -> bool:
60
+ if _REDACTED.fullmatch(value) or value.lower() in ("redacted", "changeme", "placeholder"):
61
+ return False
62
+ if value.startswith(("[[", "[", "(", "<", "{")) or "](" in value:
63
+ return False # a link or a placeholder, not a credential
64
+ if _REFERENCE.search(value):
65
+ return False
66
+ classes = sum(
67
+ bool(re.search(p, value)) for p in (r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]")
68
+ )
69
+ return classes >= 2 and _entropy(value) >= 3.5
70
+
71
+
72
+ def _keylike(body: str) -> bool:
73
+ """Generated keys mix upper case, lower case, and digits; words and slugs do not."""
74
+ return all(re.search(p, body) for p in (r"[a-z]", r"[A-Z]", r"[0-9]"))
75
+
76
+
77
+ def find_secrets(text: str) -> list[SecretHit]:
78
+ spans: list[tuple[int, int, str]] = []
79
+
80
+ def free(start: int, end: int) -> bool:
81
+ return all(end <= s or start >= e for s, e, _ in spans)
82
+
83
+ for kind, pattern in _PATTERNS:
84
+ for match in pattern.finditer(text):
85
+ if kind == "openai-key" and not _keylike(match.group(0)[3:]):
86
+ continue # `sk-` starts many words and URL slugs ("sk-learning-path-…")
87
+ if free(match.start(), match.end()):
88
+ spans.append((match.start(), match.end(), kind))
89
+ for match in _ASSIGNMENT.finditer(text):
90
+ start, end = match.span("value")
91
+ if free(start, end) and _high_entropy(match.group("value")):
92
+ spans.append((start, end, "secret-assignment"))
93
+ spans.sort()
94
+ return [SecretHit(kind, s, e, text.count("\n", 0, s) + 1) for s, e, kind in spans]
95
+
96
+
97
+ def scrub(text: str) -> tuple[str, list[str]]:
98
+ """Replace secrets with `[REDACTED:<kind>]`. Returns (text, kinds found)."""
99
+ hits = find_secrets(text)
100
+ if not hits:
101
+ return text, []
102
+ out, last = [], 0
103
+ for hit in hits:
104
+ out.append(text[last : hit.start])
105
+ out.append(f"[REDACTED:{hit.kind}]")
106
+ last = hit.end
107
+ out.append(text[last:])
108
+ return "".join(out), [h.kind for h in hits]