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.
- brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
- brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
- brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
- brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
- brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- brainwiki/__init__.py +17 -0
- brainwiki/__main__.py +5 -0
- brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
- brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
- brainwiki/assets/plugin/.mcp.json +16 -0
- brainwiki/assets/plugin/mcp.json +17 -0
- brainwiki/assets/plugin/mcp_config.json +15 -0
- brainwiki/assets/plugin/plugin.json +37 -0
- brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
- brainwiki/assets/plugin/server.json +30 -0
- brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
- brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
- brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
- brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
- brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
- brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
- brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
- brainwiki/assets/prompts/consolidate.md +89 -0
- brainwiki/assets/prompts/distill.md +96 -0
- brainwiki/assets/prompts/harvest.md +142 -0
- brainwiki/bench/__init__.py +8 -0
- brainwiki/bench/_generate.py +440 -0
- brainwiki/bench/_report.py +166 -0
- brainwiki/bench/_suites.py +959 -0
- brainwiki/cli/__init__.py +7 -0
- brainwiki/cli/_cmd_bench.py +101 -0
- brainwiki/cli/_cmd_browse.py +281 -0
- brainwiki/cli/_cmd_consolidate.py +108 -0
- brainwiki/cli/_cmd_core.py +155 -0
- brainwiki/cli/_cmd_harvest.py +137 -0
- brainwiki/cli/_cmd_hosts.py +52 -0
- brainwiki/cli/_cmd_import.py +149 -0
- brainwiki/cli/_cmd_lint.py +117 -0
- brainwiki/cli/_cmd_read.py +131 -0
- brainwiki/cli/_cmd_runs.py +66 -0
- brainwiki/cli/_cmd_viz.py +57 -0
- brainwiki/cli/_cmd_write.py +122 -0
- brainwiki/cli/_main.py +511 -0
- brainwiki/cli/_output.py +19 -0
- brainwiki/config/__init__.py +56 -0
- brainwiki/config/_dirs.py +69 -0
- brainwiki/config/_manifest.py +307 -0
- brainwiki/config/_resolve.py +169 -0
- brainwiki/config/_user.py +183 -0
- brainwiki/consolidate/__init__.py +19 -0
- brainwiki/consolidate/_apply.py +408 -0
- brainwiki/consolidate/_packet.py +489 -0
- brainwiki/consolidate/_pipeline.py +215 -0
- brainwiki/consolidate/_presets.py +230 -0
- brainwiki/consolidate/_review.py +104 -0
- brainwiki/consolidate/_schema.py +153 -0
- brainwiki/consolidate/_validate.py +252 -0
- brainwiki/doctor.py +216 -0
- brainwiki/engine.py +80 -0
- brainwiki/errors.py +81 -0
- brainwiki/format/__init__.py +112 -0
- brainwiki/format/_agents_md.py +26 -0
- brainwiki/format/_claims.py +95 -0
- brainwiki/format/_document.py +203 -0
- brainwiki/format/_edit.py +94 -0
- brainwiki/format/_episode.py +67 -0
- brainwiki/format/_fields.py +350 -0
- brainwiki/format/_frontmatter.py +175 -0
- brainwiki/format/_links.py +72 -0
- brainwiki/format/_meta.py +67 -0
- brainwiki/format/_normalize.py +70 -0
- brainwiki/format/_relations.py +211 -0
- brainwiki/format/_rules.py +41 -0
- brainwiki/harvest.py +414 -0
- brainwiki/hosts/__init__.py +22 -0
- brainwiki/hosts/_antigravity.py +163 -0
- brainwiki/hosts/_claude_code.py +174 -0
- brainwiki/hosts/_codex.py +149 -0
- brainwiki/hosts/_connect.py +443 -0
- brainwiki/hosts/_detect.py +39 -0
- brainwiki/hosts/_instructions.py +220 -0
- brainwiki/hosts/_sessions.py +176 -0
- brainwiki/ids.py +69 -0
- brainwiki/importer/__init__.py +17 -0
- brainwiki/importer/_episodes.py +112 -0
- brainwiki/importer/_into.py +102 -0
- brainwiki/importer/_normalize.py +64 -0
- brainwiki/importer/_overlay.py +199 -0
- brainwiki/importer/_survey.py +167 -0
- brainwiki/index/__init__.py +44 -0
- brainwiki/index/_brains.py +119 -0
- brainwiki/index/_db.py +131 -0
- brainwiki/index/_guards.py +154 -0
- brainwiki/index/_neighbors.py +174 -0
- brainwiki/index/_orient.py +400 -0
- brainwiki/index/_read.py +285 -0
- brainwiki/index/_resolver.py +147 -0
- brainwiki/index/_scanner.py +665 -0
- brainwiki/index/_schema.py +164 -0
- brainwiki/index/_search.py +659 -0
- brainwiki/index/_topics.py +253 -0
- brainwiki/index/_written.py +29 -0
- brainwiki/lifecycle.py +158 -0
- brainwiki/lint/__init__.py +20 -0
- brainwiki/lint/_catalog.py +85 -0
- brainwiki/lint/_cross.py +649 -0
- brainwiki/lint/_files.py +483 -0
- brainwiki/lint/_fixes.py +87 -0
- brainwiki/lint/_hook.py +54 -0
- brainwiki/lint/_model.py +103 -0
- brainwiki/lint/_reports.py +242 -0
- brainwiki/lint/_runner.py +156 -0
- brainwiki/logs.py +34 -0
- brainwiki/mcp/__init__.py +7 -0
- brainwiki/mcp/_server.py +917 -0
- brainwiki/origin.py +97 -0
- brainwiki/runs/__init__.py +21 -0
- brainwiki/runs/_git.py +89 -0
- brainwiki/runs/_lock.py +42 -0
- brainwiki/runs/_record.py +145 -0
- brainwiki/runs/_transaction.py +441 -0
- brainwiki/runs/_undo.py +159 -0
- brainwiki/setup_wizard.py +143 -0
- brainwiki/store/__init__.py +7 -0
- brainwiki/store/_local.py +153 -0
- brainwiki/viz/__init__.py +9 -0
- brainwiki/viz/_export.py +414 -0
- brainwiki/viz/_lod.py +455 -0
- brainwiki/viz/_server.py +327 -0
- brainwiki/viz/app/BUILD_INFO.json +5 -0
- brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
- brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
- brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
- brainwiki/viz/app/index.html +14 -0
- brainwiki/write/__init__.py +23 -0
- brainwiki/write/_edits.py +730 -0
- brainwiki/write/_episode.py +195 -0
- brainwiki/write/_move.py +181 -0
- brainwiki/write/_rules.py +204 -0
- 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)
|
brainwiki/write/_move.py
ADDED
|
@@ -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]
|