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,730 @@
|
|
|
1
|
+
"""Checked edits: validate direct edits of notes, then record them as runs (design doc 05 §2.3).
|
|
2
|
+
|
|
3
|
+
An agent (or the user) edits a note's file, then runs `brain check` on it. The edit is compared
|
|
4
|
+
with the note's last recorded version, kept in the index, and either recorded as a run of kind
|
|
5
|
+
`edit` with that version as its pre-image, routed to review, or refused with every problem.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import zlib
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from brainwiki.errors import Conflict, Forbidden, FormatError, NotFound, UsageError
|
|
21
|
+
from brainwiki.format import (
|
|
22
|
+
Claim,
|
|
23
|
+
clean_claim_text,
|
|
24
|
+
format_meta,
|
|
25
|
+
format_value,
|
|
26
|
+
parse_document,
|
|
27
|
+
split,
|
|
28
|
+
strip_meta_comments,
|
|
29
|
+
)
|
|
30
|
+
from brainwiki.ids import claim_id, is_prefixed
|
|
31
|
+
from brainwiki.ids import run_id as new_run_id
|
|
32
|
+
from brainwiki.store import sha256_bytes
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from brainwiki.engine import Engine
|
|
36
|
+
|
|
37
|
+
_PROTECTED_FILES = ("rules.md", "log.md", "brain.toml", "AGENTS.md")
|
|
38
|
+
_LOSS_WARN = 0.10
|
|
39
|
+
_LOSS_ERROR = 0.50
|
|
40
|
+
_DIFF_LINES = 60
|
|
41
|
+
_SYNC_COPY = re.compile(r"(?i)\.sync-conflict-|\(conflicted copy|\bconflict(ed)?[- ]copy\b")
|
|
42
|
+
_JSON_BLOCK = re.compile(r"```json\n(?P<body>.*?)\n```", re.S)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _Problem:
|
|
47
|
+
path: str
|
|
48
|
+
code: str
|
|
49
|
+
message: str
|
|
50
|
+
severity: str = "error"
|
|
51
|
+
line: int | None = None
|
|
52
|
+
|
|
53
|
+
def to_json(self) -> dict[str, Any]:
|
|
54
|
+
return {
|
|
55
|
+
"path": self.path,
|
|
56
|
+
"line": self.line,
|
|
57
|
+
"code": self.code,
|
|
58
|
+
"severity": self.severity,
|
|
59
|
+
"message": self.message,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
where = f"{self.path}:{self.line}" if self.line else self.path
|
|
64
|
+
return f"{where} {self.severity} {self.code}: {self.message}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _Change:
|
|
69
|
+
path: str
|
|
70
|
+
base: bytes | None # last recorded version; None when recorded as absent
|
|
71
|
+
current: bytes | None # the file now; None when deleted
|
|
72
|
+
final: bytes | None = None # what to record: current plus stamps
|
|
73
|
+
added_claims: int = 0
|
|
74
|
+
problems: list[_Problem] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def kind(self) -> str:
|
|
78
|
+
if self.base is None:
|
|
79
|
+
return "created"
|
|
80
|
+
return "deleted" if self.current is None else "changed"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- baselines -------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _baseline(engine: Engine, rel: str) -> tuple[bool, bytes | None]:
|
|
87
|
+
"""(whether a recorded version exists, its content; None when recorded as absent)."""
|
|
88
|
+
row = engine.db.execute("SELECT sha256, content FROM baselines WHERE path=?", (rel,)).fetchone()
|
|
89
|
+
if row is None:
|
|
90
|
+
return False, None
|
|
91
|
+
return True, zlib.decompress(row["content"]) if row["content"] is not None else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read(engine: Engine, rel: str) -> bytes | None:
|
|
95
|
+
path = engine.store.abspath(rel)
|
|
96
|
+
return path.read_bytes() if path.is_file() else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_path(engine: Engine, raw: str) -> str:
|
|
100
|
+
"""A brain-relative path from a relative or absolute one; never outside the brain."""
|
|
101
|
+
text = raw.strip()
|
|
102
|
+
if not text:
|
|
103
|
+
raise UsageError("empty path")
|
|
104
|
+
candidate = Path(text).expanduser()
|
|
105
|
+
root = engine.brain.root
|
|
106
|
+
if candidate.is_absolute():
|
|
107
|
+
try:
|
|
108
|
+
rel = candidate.resolve().relative_to(root)
|
|
109
|
+
except ValueError:
|
|
110
|
+
raise UsageError(f"{raw} is outside the brain at {root}") from None
|
|
111
|
+
text = rel.as_posix()
|
|
112
|
+
text = text.removeprefix("./")
|
|
113
|
+
if text.startswith("../") or "/../" in text or text == "..":
|
|
114
|
+
raise UsageError(f"{raw} is outside the brain")
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _protected_reason(engine: Engine, rel: str) -> str | None:
|
|
119
|
+
layout = engine.manifest.layout
|
|
120
|
+
if rel in _PROTECTED_FILES:
|
|
121
|
+
return f"{rel} is never edited directly"
|
|
122
|
+
for folder, what in (
|
|
123
|
+
(layout.inbox_dir, "inbox files (episodes and proposals)"),
|
|
124
|
+
(layout.archive_dir, "the archive"),
|
|
125
|
+
(layout.reports_dir, "reports, which brain-wiki regenerates"),
|
|
126
|
+
):
|
|
127
|
+
if rel.startswith(folder + "/"):
|
|
128
|
+
return f"{what} are never edited directly"
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _pending_edits(engine: Engine) -> list[dict[str, Any]]:
|
|
133
|
+
"""Notes whose file differs from their last recorded version, with diffs."""
|
|
134
|
+
engine.scan()
|
|
135
|
+
rows = engine.db.execute(
|
|
136
|
+
"""SELECT b.path, b.sha256 AS base, b.since, f.sha256 AS current
|
|
137
|
+
FROM baselines b LEFT JOIN files f ON f.path = b.path
|
|
138
|
+
WHERE f.sha256 IS NOT b.sha256 ORDER BY b.path"""
|
|
139
|
+
).fetchall()
|
|
140
|
+
out = []
|
|
141
|
+
for row in rows:
|
|
142
|
+
rel = row["path"]
|
|
143
|
+
_, base = _baseline(engine, rel)
|
|
144
|
+
current = _read(engine, rel)
|
|
145
|
+
if current is None and base is None:
|
|
146
|
+
continue
|
|
147
|
+
change = _Change(rel, base, current)
|
|
148
|
+
out.append(
|
|
149
|
+
{"path": rel, "change": change.kind, "since": row["since"], "diff": _diff(change)}
|
|
150
|
+
)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# -- checks ----------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _text(data: bytes | None) -> str:
|
|
158
|
+
return data.decode("utf-8", "replace") if data is not None else ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _diff(change: _Change, limit: int = _DIFF_LINES) -> str:
|
|
162
|
+
lines = list(
|
|
163
|
+
difflib.unified_diff(
|
|
164
|
+
_text(change.base).splitlines(keepends=True),
|
|
165
|
+
_text(change.final if change.final is not None else change.current).splitlines(
|
|
166
|
+
keepends=True
|
|
167
|
+
),
|
|
168
|
+
fromfile=f"a/{change.path}" if change.base is not None else "/dev/null",
|
|
169
|
+
tofile=f"b/{change.path}" if change.current is not None else "/dev/null",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if len(lines) > limit:
|
|
173
|
+
lines = [*lines[:limit], f"… {len(lines) - limit} more diff lines\n"]
|
|
174
|
+
return "".join(lines)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _words(data: bytes | None) -> list[str]:
|
|
178
|
+
if data is None:
|
|
179
|
+
return []
|
|
180
|
+
doc = parse_document("x.md", _text(data))
|
|
181
|
+
return strip_meta_comments(doc.frontmatter.body).split()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _loss(change: _Change) -> float:
|
|
185
|
+
"""The share of the recorded version's words that the edit removed."""
|
|
186
|
+
before = _words(change.base)
|
|
187
|
+
if not before:
|
|
188
|
+
return 0.0
|
|
189
|
+
after = _words(change.current)
|
|
190
|
+
matcher = difflib.SequenceMatcher(None, before, after, autojunk=False)
|
|
191
|
+
removed = sum(
|
|
192
|
+
i2 - i1 for tag, i1, i2, _, _ in matcher.get_opcodes() if tag in ("delete", "replace")
|
|
193
|
+
)
|
|
194
|
+
return removed / len(before)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _claims(data: bytes | None) -> list[Claim]:
|
|
198
|
+
return parse_document("x.md", _text(data)).claims if data is not None else []
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_SUPERSEDE = "move it to bw-claims-superseded with `by: <new claim id>`, then add the new claim"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_PROVENANCE = ("origin", "inputs", "src")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _provenance(claim: Claim) -> tuple[str, str, str]:
|
|
208
|
+
meta = claim.meta
|
|
209
|
+
return (str(meta.get("origin", "")), str(meta.get("inputs", "local")), str(meta.get("src", "")))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _check_claims(change: _Change, *, agent: bool) -> list[Claim]:
|
|
213
|
+
"""Problems for deleted or reworded claims, and for agents changing a claim's provenance;
|
|
214
|
+
returns the claims the edit added."""
|
|
215
|
+
before, after = _claims(change.base), _claims(change.current)
|
|
216
|
+
after_ids = {c.id: c for c in after if c.id}
|
|
217
|
+
after_texts = {clean_claim_text(c.text) for c in after}
|
|
218
|
+
for claim in before:
|
|
219
|
+
text = clean_claim_text(claim.text)
|
|
220
|
+
if claim.id:
|
|
221
|
+
now = after_ids.get(claim.id)
|
|
222
|
+
if now is None:
|
|
223
|
+
change.problems.append(
|
|
224
|
+
_Problem(
|
|
225
|
+
change.path,
|
|
226
|
+
"claim_deleted",
|
|
227
|
+
f'claim "{_short(text)}" was deleted; supersede it instead ({_SUPERSEDE})',
|
|
228
|
+
line=claim.line,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
elif (
|
|
232
|
+
claim.status == "active"
|
|
233
|
+
and now.status == "active"
|
|
234
|
+
and clean_claim_text(now.text) != text
|
|
235
|
+
):
|
|
236
|
+
change.problems.append(
|
|
237
|
+
_Problem(
|
|
238
|
+
change.path,
|
|
239
|
+
"claim_reworded",
|
|
240
|
+
f'claim "{_short(text)}" was reworded in place; supersede it instead '
|
|
241
|
+
f"({_SUPERSEDE}), so its history is kept",
|
|
242
|
+
line=now.line,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
elif agent and _provenance(now) != _provenance(claim):
|
|
246
|
+
change.problems.append(
|
|
247
|
+
_Problem(
|
|
248
|
+
change.path,
|
|
249
|
+
"claim_provenance_changed",
|
|
250
|
+
f'the provenance of claim "{_short(text)}" ({", ".join(_PROVENANCE)}) '
|
|
251
|
+
"changed; only brain-wiki and the user set it, so put it back",
|
|
252
|
+
line=now.line,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
elif claim.status == "active" and text not in after_texts:
|
|
256
|
+
change.problems.append(
|
|
257
|
+
_Problem(
|
|
258
|
+
change.path,
|
|
259
|
+
"claim_deleted",
|
|
260
|
+
f'claim "{_short(text)}" was deleted or reworded; supersede it instead',
|
|
261
|
+
line=claim.line,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
before_ids = {c.id for c in before if c.id}
|
|
265
|
+
before_texts = {clean_claim_text(c.text) for c in before}
|
|
266
|
+
return [
|
|
267
|
+
c
|
|
268
|
+
for c in after
|
|
269
|
+
if (c.id and c.id not in before_ids)
|
|
270
|
+
or (not c.id and clean_claim_text(c.text) not in before_texts)
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _short(text: str, width: int = 60) -> str:
|
|
275
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _stamp_claims(data: bytes, claims: list[Claim]) -> bytes:
|
|
279
|
+
"""Mark claims an agent added as its own and unverified, whatever it wrote (05 §3)."""
|
|
280
|
+
from brainwiki.format import claim_value, replace_item
|
|
281
|
+
|
|
282
|
+
text = data.decode("utf-8")
|
|
283
|
+
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
284
|
+
for claim in claims:
|
|
285
|
+
meta = dict(claim.meta)
|
|
286
|
+
meta.setdefault("id", claim_id())
|
|
287
|
+
meta["origin"] = "agent"
|
|
288
|
+
meta["inputs"] = "unknown"
|
|
289
|
+
meta.setdefault("at", today)
|
|
290
|
+
if claim.key is not None:
|
|
291
|
+
text = replace_item(text, claim.key, claim.index, [claim_value(claim.text, meta)])
|
|
292
|
+
continue
|
|
293
|
+
lines = text.splitlines(keepends=True) # a format 1 note: the claim is a body line
|
|
294
|
+
idx = claim.last_line - 1
|
|
295
|
+
if not 0 <= idx < len(lines):
|
|
296
|
+
continue
|
|
297
|
+
line = lines[idx]
|
|
298
|
+
ending = "\n" if line.endswith("\n") else ""
|
|
299
|
+
body = re.sub(r"\s*<!--.*?-->\s*$", "", line.rstrip("\n"))
|
|
300
|
+
lines[idx] = f"{body} {format_meta(meta)}{ending}"
|
|
301
|
+
text = "".join(lines)
|
|
302
|
+
return text.encode("utf-8")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _lint_new(engine: Engine, change: _Change) -> None:
|
|
306
|
+
"""File-rule problems the edit introduced (problems the note already had are not its fault)."""
|
|
307
|
+
from brainwiki.lint import lint_text
|
|
308
|
+
|
|
309
|
+
if change.current is None:
|
|
310
|
+
return
|
|
311
|
+
before = (
|
|
312
|
+
Counter((p.code, p.message) for p in lint_text(engine, change.path, _text(change.base)))
|
|
313
|
+
if change.base is not None
|
|
314
|
+
else Counter()
|
|
315
|
+
)
|
|
316
|
+
for problem in lint_text(engine, change.path, _text(change.current)):
|
|
317
|
+
key = (problem.code, problem.message)
|
|
318
|
+
if before[key]:
|
|
319
|
+
before[key] -= 1
|
|
320
|
+
continue
|
|
321
|
+
change.problems.append(
|
|
322
|
+
_Problem(
|
|
323
|
+
change.path,
|
|
324
|
+
problem.code,
|
|
325
|
+
problem.message,
|
|
326
|
+
"error" if problem.severity == "error" else "warning",
|
|
327
|
+
problem.line,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _check_ids(engine: Engine, change: _Change) -> None:
|
|
333
|
+
if change.current is None:
|
|
334
|
+
return
|
|
335
|
+
after_id = parse_document(change.path, _text(change.current)).fm.get("id")
|
|
336
|
+
if change.base is not None:
|
|
337
|
+
before_id = parse_document(change.path, _text(change.base)).fm.get("id")
|
|
338
|
+
if before_id and str(before_id) != str(after_id or ""):
|
|
339
|
+
change.problems.append(
|
|
340
|
+
_Problem(
|
|
341
|
+
change.path,
|
|
342
|
+
"id_changed",
|
|
343
|
+
f"the note's id changed from {before_id}; put it back",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
if after_id:
|
|
347
|
+
clash = engine.db.execute(
|
|
348
|
+
"SELECT path FROM nodes WHERE fm_id=? AND path != ?", (str(after_id), change.path)
|
|
349
|
+
).fetchone()
|
|
350
|
+
if clash:
|
|
351
|
+
change.problems.append(
|
|
352
|
+
_Problem(
|
|
353
|
+
change.path, "id_duplicate", f"id {after_id} is already used by {clash['path']}"
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _check_links(engine: Engine, change: _Change) -> None:
|
|
359
|
+
if change.current is None:
|
|
360
|
+
return
|
|
361
|
+
before = _text(change.base)
|
|
362
|
+
for row in engine.db.execute(
|
|
363
|
+
"""SELECT DISTINCT e.raw_target, e.line FROM edges e JOIN nodes n ON n.id = e.declared_in
|
|
364
|
+
WHERE n.path = ? AND e.other IS NULL""",
|
|
365
|
+
(change.path,),
|
|
366
|
+
):
|
|
367
|
+
if row["raw_target"] not in before:
|
|
368
|
+
change.problems.append(
|
|
369
|
+
_Problem(
|
|
370
|
+
change.path,
|
|
371
|
+
"link_unresolved",
|
|
372
|
+
f"[[{row['raw_target']}]] does not resolve to a note",
|
|
373
|
+
"warning",
|
|
374
|
+
row["line"],
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _validate(engine: Engine, change: _Change, *, allow_shrink: bool) -> list[Claim]:
|
|
380
|
+
rel = change.path
|
|
381
|
+
if _SYNC_COPY.search(rel.rsplit("/", 1)[-1]):
|
|
382
|
+
change.problems.append(
|
|
383
|
+
_Problem(
|
|
384
|
+
rel, "sync_debris", "this looks like a sync-conflict copy; merge it and delete it"
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
added = _check_claims(change, agent=engine.profile.name != "owner")
|
|
388
|
+
loss = _loss(change)
|
|
389
|
+
if loss > _LOSS_ERROR and not allow_shrink:
|
|
390
|
+
change.problems.append(
|
|
391
|
+
_Problem(
|
|
392
|
+
rel,
|
|
393
|
+
"loss",
|
|
394
|
+
f"the edit removed {loss:.0%} of the note's text; restore what was lost, or pass "
|
|
395
|
+
"allow_shrink if the removal is intended",
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
elif loss > _LOSS_WARN:
|
|
399
|
+
change.problems.append(
|
|
400
|
+
_Problem(rel, "loss", f"the edit removed {loss:.0%} of the note's text", "warning")
|
|
401
|
+
)
|
|
402
|
+
_check_ids(engine, change)
|
|
403
|
+
_lint_new(engine, change)
|
|
404
|
+
_check_links(engine, change)
|
|
405
|
+
return added
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _policy(engine: Engine, rel: str) -> str:
|
|
409
|
+
from brainwiki.config import AGENT_EDITS
|
|
410
|
+
|
|
411
|
+
policy = engine.topics.setting(engine.topics.for_path(rel), "agent_edits") or "allow"
|
|
412
|
+
return policy if policy in AGENT_EDITS else "deny"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# -- the command -----------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def check_edits(
|
|
419
|
+
engine: Engine,
|
|
420
|
+
paths: list[str] | None = None,
|
|
421
|
+
*,
|
|
422
|
+
allow_shrink: bool = False,
|
|
423
|
+
adopt: bool = False,
|
|
424
|
+
) -> dict[str, Any]:
|
|
425
|
+
"""Validate and record direct edits of `paths`; without paths, list pending edits.
|
|
426
|
+
|
|
427
|
+
Agents need the `edit` capability and follow each topic's `agent_edits` policy. `adopt`
|
|
428
|
+
(owner only) records the edits as the user's even when checks report problems.
|
|
429
|
+
"""
|
|
430
|
+
from brainwiki.index import NOTE_KINDS, classify, discovered_topics
|
|
431
|
+
|
|
432
|
+
if not paths:
|
|
433
|
+
pending = _pending_edits(engine)
|
|
434
|
+
return {"brain": engine.brain.display_name, "status": "pending", "pending": pending}
|
|
435
|
+
owner = engine.profile.name == "owner"
|
|
436
|
+
if adopt and not owner:
|
|
437
|
+
raise Forbidden("only the user can adopt edits (`brain check --adopt` from a terminal)")
|
|
438
|
+
if not owner and not engine.profile.can("edit"):
|
|
439
|
+
raise Forbidden(
|
|
440
|
+
engine.profile.refusal(engine.host, "edit", "edit notes")
|
|
441
|
+
+ "; meanwhile, log an episode instead"
|
|
442
|
+
)
|
|
443
|
+
engine.scan(force=True)
|
|
444
|
+
topics = discovered_topics(engine)
|
|
445
|
+
changes: list[_Change] = []
|
|
446
|
+
problems: list[_Problem] = []
|
|
447
|
+
unchanged: list[str] = []
|
|
448
|
+
for raw in dict.fromkeys(paths):
|
|
449
|
+
rel = _normalize_path(engine, raw)
|
|
450
|
+
reason = _protected_reason(engine, rel)
|
|
451
|
+
if reason:
|
|
452
|
+
problems.append(_Problem(rel, "protected", reason))
|
|
453
|
+
continue
|
|
454
|
+
if classify(engine, rel, topics) not in NOTE_KINDS:
|
|
455
|
+
problems.append(
|
|
456
|
+
_Problem(rel, "not_a_note", "brain_check records edits of notes and hubs only")
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
known, base = _baseline(engine, rel)
|
|
460
|
+
current = _read(engine, rel)
|
|
461
|
+
if not known and current is None:
|
|
462
|
+
raise NotFound(f"no note at {rel}")
|
|
463
|
+
if known and (sha256_bytes(base) if base is not None else None) == (
|
|
464
|
+
sha256_bytes(current) if current is not None else None
|
|
465
|
+
):
|
|
466
|
+
unchanged.append(rel)
|
|
467
|
+
continue
|
|
468
|
+
changes.append(_Change(rel, base, current))
|
|
469
|
+
review: list[_Change] = []
|
|
470
|
+
for change in changes:
|
|
471
|
+
added = _validate(engine, change, allow_shrink=allow_shrink)
|
|
472
|
+
change.final = change.current
|
|
473
|
+
if change.current is not None and added and not owner:
|
|
474
|
+
change.final = _stamp_claims(change.current, added)
|
|
475
|
+
change.added_claims = len(added)
|
|
476
|
+
if not owner:
|
|
477
|
+
policy = _policy(engine, change.path)
|
|
478
|
+
if policy == "deny":
|
|
479
|
+
change.problems.append(
|
|
480
|
+
_Problem(
|
|
481
|
+
change.path,
|
|
482
|
+
"edits_denied",
|
|
483
|
+
"this topic does not accept agent edits; restore the file "
|
|
484
|
+
"(`brain restore`) and log an episode instead",
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
elif policy == "review":
|
|
488
|
+
review.append(change)
|
|
489
|
+
problems += change.problems
|
|
490
|
+
errors = [p for p in problems if p.severity == "error"]
|
|
491
|
+
result: dict[str, Any] = {
|
|
492
|
+
"brain": engine.brain.display_name,
|
|
493
|
+
"owner": owner,
|
|
494
|
+
"unchanged": unchanged,
|
|
495
|
+
"problems": [p.to_json() for p in problems],
|
|
496
|
+
}
|
|
497
|
+
if errors and not adopt:
|
|
498
|
+
result.update(
|
|
499
|
+
status="failed",
|
|
500
|
+
files=[{"path": c.path, "change": c.kind} for c in changes],
|
|
501
|
+
)
|
|
502
|
+
return result
|
|
503
|
+
if not changes:
|
|
504
|
+
result["status"] = "unchanged" if not problems else "failed"
|
|
505
|
+
return result
|
|
506
|
+
recorded = [c for c in changes if c not in review]
|
|
507
|
+
if review:
|
|
508
|
+
result["proposal"] = _propose(engine, review)
|
|
509
|
+
if recorded:
|
|
510
|
+
record = _record(engine, recorded, owner=owner)
|
|
511
|
+
result["run"] = record.run
|
|
512
|
+
result.update(
|
|
513
|
+
status="review" if review and not recorded else "recorded",
|
|
514
|
+
files=[
|
|
515
|
+
{
|
|
516
|
+
"path": c.path,
|
|
517
|
+
"change": c.kind,
|
|
518
|
+
"added_claims": c.added_claims,
|
|
519
|
+
"outcome": "review" if c in review else "recorded",
|
|
520
|
+
}
|
|
521
|
+
for c in changes
|
|
522
|
+
],
|
|
523
|
+
)
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _record(engine: Engine, changes: list[_Change], *, owner: bool) -> Any:
|
|
528
|
+
from brainwiki.runs import Run
|
|
529
|
+
|
|
530
|
+
paths = [c.path for c in changes]
|
|
531
|
+
run = Run(
|
|
532
|
+
engine,
|
|
533
|
+
"edit",
|
|
534
|
+
agent=None if owner else engine.host,
|
|
535
|
+
summary=f"checked edit of {', '.join(paths[:3])}" + (" …" if len(paths) > 3 else ""),
|
|
536
|
+
)
|
|
537
|
+
for change in changes:
|
|
538
|
+
run.adopt(change.path, change.base, change.final)
|
|
539
|
+
return run.commit(subject=f"brain: edit {', '.join(paths[:3])}")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def restore(engine: Engine, raw: str) -> dict[str, Any]:
|
|
543
|
+
"""Put back a note's last recorded version. The discarded file is kept in the state dir."""
|
|
544
|
+
from brainwiki.config import brain_state_dir
|
|
545
|
+
|
|
546
|
+
engine.scan(force=True)
|
|
547
|
+
rel = _normalize_path(engine, raw)
|
|
548
|
+
known, base = _baseline(engine, rel)
|
|
549
|
+
if not known:
|
|
550
|
+
raise NotFound(f"no recorded version of {rel}")
|
|
551
|
+
current = _read(engine, rel)
|
|
552
|
+
if current == base:
|
|
553
|
+
return {"path": rel, "status": "unchanged"}
|
|
554
|
+
saved = None
|
|
555
|
+
if current is not None:
|
|
556
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
557
|
+
target = brain_state_dir(engine.brain.key) / "restored" / stamp / rel
|
|
558
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
559
|
+
target.write_bytes(current)
|
|
560
|
+
saved = str(target)
|
|
561
|
+
if base is None:
|
|
562
|
+
engine.store.delete(rel)
|
|
563
|
+
else:
|
|
564
|
+
engine.store.write_bytes(rel, base)
|
|
565
|
+
engine.scan(paths=[rel])
|
|
566
|
+
return {"path": rel, "status": "restored", "saved": saved, "deleted": base is None}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# -- review ----------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _propose(engine: Engine, changes: list[_Change]) -> str:
|
|
573
|
+
"""Stage edits to topics with `agent_edits = "review"` as a proposal; restore the files."""
|
|
574
|
+
from brainwiki.consolidate import proposal_path
|
|
575
|
+
from brainwiki.format import now_iso
|
|
576
|
+
|
|
577
|
+
run = new_run_id()
|
|
578
|
+
rel = proposal_path(engine, run)
|
|
579
|
+
head = {"type": "edit-proposal", "run": run, "agent": engine.host, "created": now_iso()}
|
|
580
|
+
payload = {
|
|
581
|
+
"files": [
|
|
582
|
+
{
|
|
583
|
+
"path": c.path,
|
|
584
|
+
"base": sha256_bytes(c.base) if c.base is not None else None,
|
|
585
|
+
"content": _text(c.final) if c.final is not None else None,
|
|
586
|
+
}
|
|
587
|
+
for c in changes
|
|
588
|
+
]
|
|
589
|
+
}
|
|
590
|
+
lines = ["---", *[f"{k}: {format_value(v)}" for k, v in head.items() if v], "---"]
|
|
591
|
+
lines += [f"# Edit proposal {run}", "", "Review needed because:", ""]
|
|
592
|
+
lines += sorted({f"- topic of {c.path} requires review for agent edits" for c in changes})
|
|
593
|
+
lines += ["", f"Accept: `brain review accept {run}` · Reject: `brain review reject {run}`", ""]
|
|
594
|
+
lines += ["## Diff", "", "```diff", *[_diff(c, 400).rstrip("\n") for c in changes], "```", ""]
|
|
595
|
+
lines += ["## Edit", "", "```json", json.dumps(payload, ensure_ascii=False), "```", ""]
|
|
596
|
+
engine.store.write_text(rel, "\n".join(lines))
|
|
597
|
+
for change in changes:
|
|
598
|
+
if change.base is None:
|
|
599
|
+
if engine.store.exists(change.path):
|
|
600
|
+
engine.store.delete(change.path)
|
|
601
|
+
else:
|
|
602
|
+
engine.store.write_bytes(change.path, change.base)
|
|
603
|
+
engine.scan(paths=[c.path for c in changes] + [rel])
|
|
604
|
+
return rel
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def is_edit_proposal(engine: Engine, run: str) -> bool:
|
|
608
|
+
from brainwiki.consolidate import proposal_path
|
|
609
|
+
|
|
610
|
+
rel = proposal_path(engine, run)
|
|
611
|
+
if not is_prefixed(run, "run-") or not engine.store.exists(rel):
|
|
612
|
+
return False
|
|
613
|
+
return split(engine.store.read_text(rel)).data.get("type") == "edit-proposal"
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _load(engine: Engine, run: str) -> tuple[str, dict[str, Any], str | None]:
|
|
617
|
+
from brainwiki.consolidate import proposal_path
|
|
618
|
+
|
|
619
|
+
rel = proposal_path(engine, run)
|
|
620
|
+
text = engine.store.read_text(rel)
|
|
621
|
+
blocks = list(_JSON_BLOCK.finditer(text))
|
|
622
|
+
try:
|
|
623
|
+
payload = json.loads(blocks[-1].group("body"))
|
|
624
|
+
payload["files"] # noqa: B018 - validate shape
|
|
625
|
+
except (IndexError, ValueError, KeyError, TypeError) as exc:
|
|
626
|
+
raise FormatError(f"{rel}: the edit block is not valid ({exc}); nothing applied") from exc
|
|
627
|
+
agent = split(text).data.get("agent")
|
|
628
|
+
return rel, payload, str(agent) if agent else None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _proposal_files(
|
|
632
|
+
engine: Engine, run: str
|
|
633
|
+
) -> tuple[str, list[tuple[str, str | None, str | None]], str | None]:
|
|
634
|
+
"""The files a proposal changes, refusing any an edit may not touch: the proposal file is
|
|
635
|
+
in the inbox, where anything can write it, so its payload is checked like a new edit."""
|
|
636
|
+
from brainwiki.index import NOTE_KINDS, classify, discovered_topics
|
|
637
|
+
|
|
638
|
+
rel, payload, agent = _load(engine, run)
|
|
639
|
+
topics = discovered_topics(engine)
|
|
640
|
+
files = []
|
|
641
|
+
for item in payload["files"]:
|
|
642
|
+
path = _normalize_path(engine, str(item.get("path", "")))
|
|
643
|
+
reason = _protected_reason(engine, path)
|
|
644
|
+
if reason is None and classify(engine, path, topics) not in NOTE_KINDS:
|
|
645
|
+
reason = "it is not a note"
|
|
646
|
+
if reason:
|
|
647
|
+
raise Forbidden(
|
|
648
|
+
f"proposal {run} would change {path}, which an edit may not touch ({reason}); "
|
|
649
|
+
f"reject it with `brain review reject {run}`"
|
|
650
|
+
)
|
|
651
|
+
files.append((path, item.get("base"), item.get("content")))
|
|
652
|
+
return rel, files, agent
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def show_proposal(engine: Engine, run: str) -> str:
|
|
656
|
+
"""What accepting the proposal would change, diffed from its payload, not its own text."""
|
|
657
|
+
_, files, agent = _proposal_files(engine, run)
|
|
658
|
+
parts = [f"Edit proposal {run} from {agent or 'an unknown agent'}. Accepting applies:"]
|
|
659
|
+
for path, _, content in files:
|
|
660
|
+
new = content.encode("utf-8") if content is not None else None
|
|
661
|
+
parts.append(_diff(_Change(path, _read(engine, path), new)).rstrip("\n"))
|
|
662
|
+
parts.append(f"Accept: brain review accept {run} Reject: brain review reject {run}")
|
|
663
|
+
return "\n\n".join(parts)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def accept_proposal(engine: Engine, run: str) -> Any:
|
|
667
|
+
from brainwiki.runs import Run
|
|
668
|
+
|
|
669
|
+
rel, files, agent = _proposal_files(engine, run)
|
|
670
|
+
record_run = Run(engine, "edit", agent=agent, backend="review", summary=f"accepted {run}")
|
|
671
|
+
for path, base, content in files:
|
|
672
|
+
if engine.store.sha(path) != base:
|
|
673
|
+
raise Conflict(f"{path} changed since the proposal; ask the agent to edit it again")
|
|
674
|
+
if content is None:
|
|
675
|
+
record_run.delete(path)
|
|
676
|
+
else:
|
|
677
|
+
record_run.write(path, content)
|
|
678
|
+
record_run.delete(rel)
|
|
679
|
+
return record_run.commit(
|
|
680
|
+
log_lines=[f"- accepted edit proposal {run} (agent: {agent or 'unknown'})"]
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def reject_proposal(engine: Engine, run: str) -> Any:
|
|
685
|
+
from brainwiki.runs import Run
|
|
686
|
+
|
|
687
|
+
rel, _, _ = _load(engine, run)
|
|
688
|
+
record_run = Run(engine, "review-reject", summary=f"rejected proposal {run}")
|
|
689
|
+
record_run.delete(rel)
|
|
690
|
+
return record_run.commit(log_lines=[f"- rejected edit proposal {run}"])
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def format_check_text(result: dict[str, Any]) -> str:
|
|
694
|
+
status = result["status"]
|
|
695
|
+
if status == "pending":
|
|
696
|
+
items = result["pending"]
|
|
697
|
+
if not items:
|
|
698
|
+
return "No pending edits: every note matches its last recorded version."
|
|
699
|
+
lines = [f"{len(items)} pending edit(s):"]
|
|
700
|
+
for item in items:
|
|
701
|
+
lines += [f"- {item['path']} ({item['change']})", item["diff"].rstrip("\n")]
|
|
702
|
+
lines.append("Record with `brain check PATH`, or put back with `brain restore PATH`.")
|
|
703
|
+
return "\n".join(lines)
|
|
704
|
+
lines = []
|
|
705
|
+
if status == "recorded":
|
|
706
|
+
lines.append(f"Recorded as {result['run']} (undo: brain undo {result['run']}).")
|
|
707
|
+
elif status == "review":
|
|
708
|
+
lines.append(f"Sent to the user for review: {result['proposal']}. The files were restored.")
|
|
709
|
+
elif status == "unchanged":
|
|
710
|
+
lines.append("Nothing to record: the files match their last recorded version.")
|
|
711
|
+
else:
|
|
712
|
+
lines.append(
|
|
713
|
+
"Not recorded. Fix these problems and check again, or `brain restore PATH`"
|
|
714
|
+
+ (
|
|
715
|
+
"; to keep your edit as it is, `brain check --adopt PATH`:"
|
|
716
|
+
if result.get("owner")
|
|
717
|
+
else ":"
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
if result.get("proposal") and status == "recorded":
|
|
721
|
+
lines.append(f"Some edits went to review: {result['proposal']}; those files were restored.")
|
|
722
|
+
for item in result.get("files") or []:
|
|
723
|
+
if item.get("added_claims"):
|
|
724
|
+
lines.append(
|
|
725
|
+
f"- {item['path']}: {item['added_claims']} new claim(s) marked unverified (origin agent)."
|
|
726
|
+
)
|
|
727
|
+
for problem in result.get("problems") or []:
|
|
728
|
+
where = f"{problem['path']}:{problem['line']}" if problem["line"] else problem["path"]
|
|
729
|
+
lines.append(f"- {where} {problem['severity']} {problem['code']}: {problem['message']}")
|
|
730
|
+
return "\n".join(lines)
|