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,101 @@
|
|
|
1
|
+
"""bench: run the evaluation suites (design doc 11)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from brainwiki.errors import UsageError
|
|
15
|
+
|
|
16
|
+
_SUITES = ("retrieval", "lint", "consolidation", "perf", "agent")
|
|
17
|
+
_DEFAULT_SUITES = ["retrieval", "lint", "perf"]
|
|
18
|
+
_ENV = ("BRAIN_WIKI_CONFIG", "BRAIN_WIKI_STATE_DIR", "BRAIN_WIKI_CACHE_DIR", "BRAIN_WIKI_DATA_DIR")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_sizes(raw: str) -> list[int]:
|
|
22
|
+
sizes = []
|
|
23
|
+
for item in (s.strip().lower() for s in raw.split(",") if s.strip()):
|
|
24
|
+
try:
|
|
25
|
+
sizes.append(int(float(item[:-1]) * 1000) if item.endswith("k") else int(item))
|
|
26
|
+
except ValueError as exc:
|
|
27
|
+
raise UsageError(f"bad size {item!r}; use e.g. 1k, 10k or 200") from exc
|
|
28
|
+
if not sizes or min(sizes) < 50:
|
|
29
|
+
raise UsageError("sizes must be at least 50 notes")
|
|
30
|
+
return sizes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def _isolated_state(base: Path) -> Iterator[None]:
|
|
35
|
+
"""Keep bench indexes, runs, and user config away from the user's own brain-wiki state."""
|
|
36
|
+
saved = {k: os.environ.get(k) for k in (*_ENV, "BRAIN_WIKI_BRAIN")}
|
|
37
|
+
os.environ["BRAIN_WIKI_CONFIG"] = str(base / "config.toml")
|
|
38
|
+
for key, sub in zip(_ENV[1:], ("state", "cache", "data"), strict=True):
|
|
39
|
+
os.environ[key] = str(base / sub)
|
|
40
|
+
os.environ.pop("BRAIN_WIKI_BRAIN", None)
|
|
41
|
+
try:
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
for key, value in saved.items():
|
|
45
|
+
if value is None:
|
|
46
|
+
os.environ.pop(key, None)
|
|
47
|
+
else:
|
|
48
|
+
os.environ[key] = value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bench(args: Any) -> int:
|
|
52
|
+
from brainwiki.bench import report, suites
|
|
53
|
+
|
|
54
|
+
if args.compare:
|
|
55
|
+
text, regressions = report.compare(Path(args.compare[0]), Path(args.compare[1]))
|
|
56
|
+
print(text)
|
|
57
|
+
return 1 if regressions else 0
|
|
58
|
+
names = args.suites or _DEFAULT_SUITES
|
|
59
|
+
unknown = [n for n in names if n not in _SUITES]
|
|
60
|
+
if unknown:
|
|
61
|
+
raise UsageError(f"unknown suite(s) {', '.join(unknown)}; choose from {', '.join(_SUITES)}")
|
|
62
|
+
sizes = _parse_sizes(args.sizes)
|
|
63
|
+
backends = [b.strip() for b in (args.backends or "").split(",") if b.strip()]
|
|
64
|
+
out = Path(args.out) if args.out else Path(tempfile.mkdtemp(prefix="brain-bench-"))
|
|
65
|
+
work = out / "work"
|
|
66
|
+
if work.exists():
|
|
67
|
+
shutil.rmtree(work) # generated brains from an earlier run in the same --out
|
|
68
|
+
work.mkdir(parents=True)
|
|
69
|
+
|
|
70
|
+
results = []
|
|
71
|
+
with _isolated_state(work / ".state"):
|
|
72
|
+
for name in names:
|
|
73
|
+
started = time.monotonic()
|
|
74
|
+
if name in ("retrieval", "perf"):
|
|
75
|
+
result = getattr(suites, name)(work, sizes[0])
|
|
76
|
+
for size in sizes[1:]:
|
|
77
|
+
extra = getattr(suites, name)(work, size)
|
|
78
|
+
result.metrics += extra.metrics
|
|
79
|
+
result.details.update({f"{k}@{size}": v for k, v in extra.details.items()})
|
|
80
|
+
elif name == "lint":
|
|
81
|
+
result = suites.lint_suite(work)
|
|
82
|
+
else:
|
|
83
|
+
result = getattr(suites, name)(work, backends)
|
|
84
|
+
result.details["seconds"] = round(time.monotonic() - started, 1)
|
|
85
|
+
results.append(result)
|
|
86
|
+
if not args.quiet:
|
|
87
|
+
print(f"{name}: {result.status} ({result.details['seconds']} s)", flush=True)
|
|
88
|
+
|
|
89
|
+
command = " ".join(["brain bench", *names, "--sizes", args.sizes])
|
|
90
|
+
if backends:
|
|
91
|
+
command += " --backends " + ",".join(backends)
|
|
92
|
+
data = report.build(results, command=command, sizes=sizes, backends=backends)
|
|
93
|
+
results_path, report_path = report.write(out, data)
|
|
94
|
+
print(report.table(data))
|
|
95
|
+
failures = report.hard_failures(data)
|
|
96
|
+
if failures:
|
|
97
|
+
print(f"\n{len(failures)} hard target(s) failed:")
|
|
98
|
+
for m in failures[:20]:
|
|
99
|
+
print(f" {m['suite']} {m['case']} {m['metric']} = {m['value']} (target {m['target']})")
|
|
100
|
+
print(f"\nResults: {results_path}\nReport: {report_path}")
|
|
101
|
+
return 1 if failures else 0
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Small browsing commands in the Obsidian CLI's style (design doc 07 §2.2).
|
|
2
|
+
|
|
3
|
+
Targets resolve like wikilinks; every line names the exact path, the handle for the next
|
|
4
|
+
command. Listings share --count, --paths, --limit, and --cursor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from brainwiki.cli._output import emit
|
|
13
|
+
from brainwiki.engine import Engine, open_engine
|
|
14
|
+
from brainwiki.index import node_filters, parse_cursor
|
|
15
|
+
|
|
16
|
+
_NOTE_KINDS = ["note", "hub"]
|
|
17
|
+
_STATUSES = ["active", "superseded"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _engine(args: Any) -> Engine:
|
|
21
|
+
return open_engine(args.brain, host=getattr(args, "as_host", None))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _note_where(engine: Engine) -> tuple[str, list[Any]]:
|
|
25
|
+
return node_filters(engine, _NOTE_KINDS, _STATUSES, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _listing(
|
|
29
|
+
args: Any,
|
|
30
|
+
rows: list[dict[str, Any]],
|
|
31
|
+
line: Callable[[dict[str, Any]], str],
|
|
32
|
+
*,
|
|
33
|
+
empty: str,
|
|
34
|
+
path_key: str = "path",
|
|
35
|
+
) -> int:
|
|
36
|
+
"""Print a list with the shared flags; JSON carries the page plus the total."""
|
|
37
|
+
total = len(rows)
|
|
38
|
+
offset = parse_cursor(getattr(args, "cursor", None))
|
|
39
|
+
limit = getattr(args, "limit", None) or total
|
|
40
|
+
page = rows[offset : offset + limit]
|
|
41
|
+
more = offset + len(page) < total
|
|
42
|
+
data = {"total": total, "items": page, "next_cursor": str(offset + len(page)) if more else None}
|
|
43
|
+
if getattr(args, "count", False):
|
|
44
|
+
emit(args, data, str(total))
|
|
45
|
+
return 0
|
|
46
|
+
if getattr(args, "paths", False):
|
|
47
|
+
text = "\n".join(dict.fromkeys(str(r[path_key]) for r in page if r.get(path_key)))
|
|
48
|
+
else:
|
|
49
|
+
text = "\n".join(line(r) for r in page) or empty
|
|
50
|
+
if more:
|
|
51
|
+
text += f"\n… {total - offset - len(page)} more (--cursor {offset + len(page)})"
|
|
52
|
+
emit(args, data, text)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def topics(args: Any) -> int:
|
|
57
|
+
"""The topic tree: slug, title, notes, and sub-topics indented below."""
|
|
58
|
+
from brainwiki.errors import NotFound
|
|
59
|
+
from brainwiki.index import topic_rows
|
|
60
|
+
|
|
61
|
+
with _engine(args) as engine:
|
|
62
|
+
engine.scan()
|
|
63
|
+
rows = {r["slug"]: r for r in topic_rows(engine)}
|
|
64
|
+
if args.topic and args.topic not in rows:
|
|
65
|
+
raise NotFound(f"no topic {args.topic!r}")
|
|
66
|
+
starts = (
|
|
67
|
+
[args.topic] if args.topic else sorted(s for s, r in rows.items() if r["parent"] is None)
|
|
68
|
+
)
|
|
69
|
+
items: list[dict[str, Any]] = []
|
|
70
|
+
|
|
71
|
+
def walk(slug: str, depth: int) -> None:
|
|
72
|
+
items.append({**rows[slug], "depth": depth})
|
|
73
|
+
for child in sorted(s for s, r in rows.items() if r["parent"] == slug):
|
|
74
|
+
walk(child, depth + 1)
|
|
75
|
+
|
|
76
|
+
for slug in starts:
|
|
77
|
+
walk(slug, 0)
|
|
78
|
+
|
|
79
|
+
def line(r: dict[str, Any]) -> str:
|
|
80
|
+
own = f"{r['notes']} note{'' if r['notes'] == 1 else 's'}"
|
|
81
|
+
if r["sub_topics"]:
|
|
82
|
+
own += f", {r['total_notes']} with sub-topics"
|
|
83
|
+
return f"{' ' * r['depth']}- {r['slug']}: {r['title']} ({own}) [{r['path']}]"
|
|
84
|
+
|
|
85
|
+
return _listing(args, items, line, empty="(no topics)")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def notes(args: Any) -> int:
|
|
89
|
+
from brainwiki.index import format_search_text, search
|
|
90
|
+
|
|
91
|
+
with _engine(args) as engine:
|
|
92
|
+
result = search(
|
|
93
|
+
engine,
|
|
94
|
+
"",
|
|
95
|
+
limit=args.limit,
|
|
96
|
+
topics=args.topic,
|
|
97
|
+
types=args.types,
|
|
98
|
+
tags=args.tags,
|
|
99
|
+
sort=args.sort,
|
|
100
|
+
cursor=args.cursor,
|
|
101
|
+
count_only=args.count,
|
|
102
|
+
facets=args.facets,
|
|
103
|
+
intent="none",
|
|
104
|
+
)
|
|
105
|
+
if args.count and not args.json and not args.facets:
|
|
106
|
+
print(result["total"])
|
|
107
|
+
return 0
|
|
108
|
+
emit(args, result, format_search_text(result, "paths" if args.paths else "text"))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def recent(args: Any) -> int:
|
|
113
|
+
args.topic, args.types, args.tags, args.sort, args.facets = None, None, None, "recent", False
|
|
114
|
+
from brainwiki.index import format_search_text, search
|
|
115
|
+
|
|
116
|
+
with _engine(args) as engine:
|
|
117
|
+
result = search(
|
|
118
|
+
engine,
|
|
119
|
+
"",
|
|
120
|
+
limit=args.limit,
|
|
121
|
+
since=args.since,
|
|
122
|
+
cursor=args.cursor,
|
|
123
|
+
count_only=args.count,
|
|
124
|
+
)
|
|
125
|
+
if args.count and not args.json:
|
|
126
|
+
print(result["total"])
|
|
127
|
+
return 0
|
|
128
|
+
emit(args, result, format_search_text(result, "paths" if args.paths else "text"))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def outline(args: Any) -> int:
|
|
133
|
+
from brainwiki.format import parse_document
|
|
134
|
+
from brainwiki.index import resolve_target
|
|
135
|
+
|
|
136
|
+
with _engine(args) as engine:
|
|
137
|
+
node = resolve_target(engine, args.target)
|
|
138
|
+
doc = parse_document(node["path"], engine.store.read_text(node["path"]))
|
|
139
|
+
headings = [{"line": line, "level": level, "text": text} for line, level, text in doc.headings]
|
|
140
|
+
text = "\n".join(
|
|
141
|
+
[f"{node['title']} [{node['path']}]"]
|
|
142
|
+
+ [f"{' ' * (h['level'] - 1)}- {h['text']} (line {h['line']})" for h in headings]
|
|
143
|
+
)
|
|
144
|
+
emit(args, {"path": node["path"], "headings": headings}, text)
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def links(args: Any) -> int:
|
|
149
|
+
"""Everything a note links to: typed relations and body links, resolved or not."""
|
|
150
|
+
from brainwiki.index import resolve_target
|
|
151
|
+
|
|
152
|
+
with _engine(args) as engine:
|
|
153
|
+
node = resolve_target(engine, args.target)
|
|
154
|
+
rows = []
|
|
155
|
+
for edge in engine.db.execute(
|
|
156
|
+
"""SELECT e.verb, e.rel, e.raw_target, e.line, n.id, n.title, n.path
|
|
157
|
+
FROM edges e LEFT JOIN nodes n ON n.id = e.other
|
|
158
|
+
WHERE e.declared_in = ? ORDER BY e.line""",
|
|
159
|
+
(node["id"],),
|
|
160
|
+
):
|
|
161
|
+
rows.append(
|
|
162
|
+
{
|
|
163
|
+
"relation": edge["verb"],
|
|
164
|
+
"target": edge["raw_target"],
|
|
165
|
+
"line": edge["line"],
|
|
166
|
+
"id": edge["id"],
|
|
167
|
+
"title": edge["title"],
|
|
168
|
+
"path": edge["path"],
|
|
169
|
+
"resolved": edge["id"] is not None,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return _listing(
|
|
173
|
+
args,
|
|
174
|
+
rows,
|
|
175
|
+
lambda r: (
|
|
176
|
+
f'- {r["relation"]} "{r["title"]}" ({r["path"]})'
|
|
177
|
+
if r["path"]
|
|
178
|
+
else f"- {r['relation']} [[{r['target']}]]"
|
|
179
|
+
+ ("" if r["resolved"] else " (unresolved)")
|
|
180
|
+
+ f" line {r['line']}"
|
|
181
|
+
),
|
|
182
|
+
empty="No links.",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def backlinks(args: Any) -> int:
|
|
187
|
+
from brainwiki.index import resolve_target
|
|
188
|
+
|
|
189
|
+
with _engine(args) as engine:
|
|
190
|
+
node = resolve_target(engine, args.target)
|
|
191
|
+
rows, seen = [], set()
|
|
192
|
+
for edge in engine.db.execute(
|
|
193
|
+
"""SELECT e.verb, n.id, n.title, n.path
|
|
194
|
+
FROM edges e JOIN nodes n ON n.id = e.declared_in
|
|
195
|
+
WHERE e.other = ? AND e.declared_in != ?
|
|
196
|
+
ORDER BY e.rel = 'links_to', n.updated DESC""",
|
|
197
|
+
(node["id"], node["id"]),
|
|
198
|
+
):
|
|
199
|
+
key = (edge["id"], edge["verb"])
|
|
200
|
+
if key in seen:
|
|
201
|
+
continue
|
|
202
|
+
seen.add(key)
|
|
203
|
+
rows.append(
|
|
204
|
+
{
|
|
205
|
+
"relation": edge["verb"],
|
|
206
|
+
"id": edge["id"],
|
|
207
|
+
"title": edge["title"],
|
|
208
|
+
"path": edge["path"],
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return _listing(
|
|
212
|
+
args,
|
|
213
|
+
rows,
|
|
214
|
+
lambda r: f'- {r["relation"]} "{r["title"]}" ({r["path"]})',
|
|
215
|
+
empty="No backlinks.",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _counts(args: Any, select: str, source: str, what: str) -> int:
|
|
220
|
+
with _engine(args) as engine:
|
|
221
|
+
where, params = _note_where(engine)
|
|
222
|
+
rows = [
|
|
223
|
+
{"name": r["k"], "count": r["c"]}
|
|
224
|
+
for r in engine.db.execute(
|
|
225
|
+
f"SELECT {select} AS k, COUNT(DISTINCT n.id) AS c FROM {source} WHERE {where} " # noqa: S608
|
|
226
|
+
f"AND {select} IS NOT NULL GROUP BY {select} ORDER BY c DESC, k",
|
|
227
|
+
params,
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
return _listing(
|
|
231
|
+
args,
|
|
232
|
+
rows,
|
|
233
|
+
lambda r: f"{r['name']} {r['count']}" if args.counts else str(r["name"]),
|
|
234
|
+
empty=f"No {what}.",
|
|
235
|
+
path_key="name",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def tags(args: Any) -> int:
|
|
240
|
+
return _counts(args, "lower(t.value)", "nodes n, json_each(n.tags) t", "tags")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def types(args: Any) -> int:
|
|
244
|
+
return _counts(args, "n.type", "nodes n", "types")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def orphans(args: Any) -> int:
|
|
248
|
+
"""Notes nothing else links to (hubs are entry points, so they are not listed)."""
|
|
249
|
+
with _engine(args) as engine:
|
|
250
|
+
where, params = _note_where(engine)
|
|
251
|
+
rows = []
|
|
252
|
+
for row in engine.db.execute(
|
|
253
|
+
f"""SELECT n.id, n.title, n.path, n.type FROM nodes n WHERE {where} AND n.kind = 'note'
|
|
254
|
+
AND NOT EXISTS (SELECT 1 FROM edges e JOIN nodes s ON s.id = e.declared_in
|
|
255
|
+
WHERE e.other = n.id AND e.declared_in != n.id)
|
|
256
|
+
ORDER BY n.path""", # noqa: S608
|
|
257
|
+
params,
|
|
258
|
+
):
|
|
259
|
+
rows.append(dict(row))
|
|
260
|
+
return _listing(args, rows, lambda r: f'- "{r["title"]}" ({r["path"]})', empty="No orphans.")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def unresolved(args: Any) -> int:
|
|
264
|
+
"""Links and relations that resolve to no note."""
|
|
265
|
+
with _engine(args) as engine:
|
|
266
|
+
where, params = _note_where(engine)
|
|
267
|
+
rows = [
|
|
268
|
+
{"target": r["raw_target"], "path": r["path"], "line": r["line"], "relation": r["verb"]}
|
|
269
|
+
for r in engine.db.execute(
|
|
270
|
+
f"""SELECT e.raw_target, e.line, e.verb, n.path FROM edges e
|
|
271
|
+
JOIN nodes n ON n.id = e.declared_in
|
|
272
|
+
WHERE e.other IS NULL AND {where} ORDER BY e.raw_target, n.path, e.line""", # noqa: S608
|
|
273
|
+
params,
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
return _listing(
|
|
277
|
+
args,
|
|
278
|
+
rows,
|
|
279
|
+
lambda r: f"[[{r['target']}]] {r['path']}:{r['line']} ({r['relation']})",
|
|
280
|
+
empty="Every link resolves.",
|
|
281
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""consolidate, review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from brainwiki.cli._output import emit
|
|
11
|
+
from brainwiki.engine import open_engine
|
|
12
|
+
from brainwiki.errors import UsageError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def consolidate(args: Any) -> int:
|
|
16
|
+
from brainwiki.consolidate import pipeline
|
|
17
|
+
|
|
18
|
+
host = getattr(args, "as_host", None)
|
|
19
|
+
with open_engine(args.brain, host=host) as engine:
|
|
20
|
+
if args.apply_file:
|
|
21
|
+
if not args.run:
|
|
22
|
+
raise UsageError("--apply needs --run RUN (the id from --prepare)")
|
|
23
|
+
raw = (
|
|
24
|
+
sys.stdin.read()
|
|
25
|
+
if args.apply_file == "-"
|
|
26
|
+
else Path(args.apply_file).read_text(encoding="utf-8")
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
payload = json.loads(raw)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise UsageError(f"{args.apply_file} is not JSON: {exc}") from exc
|
|
32
|
+
result = pipeline.apply_run(
|
|
33
|
+
engine,
|
|
34
|
+
args.run,
|
|
35
|
+
payload,
|
|
36
|
+
engine.profile,
|
|
37
|
+
dry_run=args.dry_run,
|
|
38
|
+
agent=host,
|
|
39
|
+
backend="agent",
|
|
40
|
+
)
|
|
41
|
+
emit(args, result.to_json(), result.format_text())
|
|
42
|
+
return 0 if result.status in ("applied", "review", "dry_run") else 1
|
|
43
|
+
if args.prepare:
|
|
44
|
+
packet = pipeline.prepare(
|
|
45
|
+
engine, engine.profile, topic=args.topic, max_episodes=args.max_episodes
|
|
46
|
+
)
|
|
47
|
+
if packet is None:
|
|
48
|
+
emit(args, {"run": None, "pending": 0}, "Nothing to consolidate.")
|
|
49
|
+
return 0
|
|
50
|
+
text = pipeline.instructions() + "\n" + packet.model_text() + "\n"
|
|
51
|
+
if args.out:
|
|
52
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
53
|
+
emit(args, {"run": packet.run, "out": args.out}, f"{packet.run}: wrote {args.out}")
|
|
54
|
+
else:
|
|
55
|
+
emit(
|
|
56
|
+
args,
|
|
57
|
+
{
|
|
58
|
+
"run": packet.run,
|
|
59
|
+
"instructions": pipeline.instructions(),
|
|
60
|
+
"packet": packet.data,
|
|
61
|
+
},
|
|
62
|
+
text,
|
|
63
|
+
)
|
|
64
|
+
return 0
|
|
65
|
+
raise UsageError(
|
|
66
|
+
"consolidation runs in your agent's session: ask it to consolidate the brain. "
|
|
67
|
+
"To drive it yourself, run `brain consolidate --prepare`, then `--apply OPS.json --run RUN`"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def review(args: Any) -> int:
|
|
72
|
+
from brainwiki.consolidate import list_proposals, load_proposal, pipeline
|
|
73
|
+
|
|
74
|
+
with open_engine(args.brain, host=getattr(args, "as_host", None)) as engine:
|
|
75
|
+
if args.action == "list":
|
|
76
|
+
items = list_proposals(engine)
|
|
77
|
+
text = "\n".join(
|
|
78
|
+
f"{p['run']} {p.get('agent') or ''} {p.get('created') or ''}" for p in items
|
|
79
|
+
)
|
|
80
|
+
emit(args, items, text or "No proposals.")
|
|
81
|
+
return 0
|
|
82
|
+
if not args.run:
|
|
83
|
+
raise UsageError(f"brain review {args.action} needs a run id")
|
|
84
|
+
from brainwiki.write import edits
|
|
85
|
+
|
|
86
|
+
if edits.is_edit_proposal(engine, args.run):
|
|
87
|
+
if args.action == "show":
|
|
88
|
+
text = edits.show_proposal(engine, args.run)
|
|
89
|
+
emit(args, {"run": args.run, "type": "edit-proposal"}, text)
|
|
90
|
+
return 0
|
|
91
|
+
if args.action == "accept":
|
|
92
|
+
record = edits.accept_proposal(engine, args.run)
|
|
93
|
+
emit(args, record.to_json(), f"Accepted {args.run} as {record.run}.")
|
|
94
|
+
return 0
|
|
95
|
+
record = edits.reject_proposal(engine, args.run)
|
|
96
|
+
emit(args, record.to_json(), f"Rejected {args.run}; the note is unchanged.")
|
|
97
|
+
return 0
|
|
98
|
+
if args.action == "show":
|
|
99
|
+
_, ops, agent, text = load_proposal(engine, args.run)
|
|
100
|
+
emit(args, {"run": args.run, "agent": agent, "ops": ops}, text)
|
|
101
|
+
return 0
|
|
102
|
+
if args.action == "accept":
|
|
103
|
+
result = pipeline.accept_review(engine, args.run, engine.profile)
|
|
104
|
+
emit(args, result.to_json(), result.format_text())
|
|
105
|
+
return 0 if result.status == "applied" else 1
|
|
106
|
+
record = pipeline.reject_review(engine, args.run)
|
|
107
|
+
emit(args, record.to_json(), f"Rejected {args.run}; its episodes stay pending.")
|
|
108
|
+
return 0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""version, init, list, brains, doctor, setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from brainwiki.cli._output import emit
|
|
10
|
+
from brainwiki.config import (
|
|
11
|
+
load_user_config,
|
|
12
|
+
open_brain,
|
|
13
|
+
register_brain,
|
|
14
|
+
set_value,
|
|
15
|
+
unregister_brain,
|
|
16
|
+
)
|
|
17
|
+
from brainwiki.errors import Conflict, NotFound, UsageError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def version(args: Any) -> int:
|
|
21
|
+
from brainwiki import FORMAT_VERSION, __version__
|
|
22
|
+
|
|
23
|
+
emit(
|
|
24
|
+
args,
|
|
25
|
+
{"version": __version__, "format": FORMAT_VERSION},
|
|
26
|
+
f"brain-wiki {__version__} (format {FORMAT_VERSION})",
|
|
27
|
+
)
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def init(args: Any) -> int:
|
|
32
|
+
from brainwiki.config import registration_conflict
|
|
33
|
+
from brainwiki.lifecycle import init_brain, init_git
|
|
34
|
+
|
|
35
|
+
target = Path(args.path).expanduser()
|
|
36
|
+
if not args.no_register:
|
|
37
|
+
problem = registration_conflict(args.name or target.resolve().name, target)
|
|
38
|
+
if problem:
|
|
39
|
+
raise Conflict(problem)
|
|
40
|
+
root = init_brain(target, args.name)
|
|
41
|
+
brain = open_brain(root)
|
|
42
|
+
name = args.name or brain.manifest.name or root.name
|
|
43
|
+
if not args.no_register:
|
|
44
|
+
register_brain(name, root)
|
|
45
|
+
git_problem = init_git(root, name) if args.git else None
|
|
46
|
+
if not args.git:
|
|
47
|
+
git_line = "\n tip: `brain init --git` or `git init` to track changes"
|
|
48
|
+
elif git_problem:
|
|
49
|
+
git_line = f"\n git: {git_problem}; commit the brain's files yourself"
|
|
50
|
+
else:
|
|
51
|
+
git_line = "\n git: initialized, with a first commit"
|
|
52
|
+
emit(
|
|
53
|
+
args,
|
|
54
|
+
{
|
|
55
|
+
"path": str(root),
|
|
56
|
+
"name": name,
|
|
57
|
+
"id": brain.id,
|
|
58
|
+
"registered": not args.no_register,
|
|
59
|
+
"git": bool(args.git) and git_problem is None,
|
|
60
|
+
},
|
|
61
|
+
f"Created brain {name!r} at {root}\n id: {brain.id}"
|
|
62
|
+
+ ("" if args.no_register else "\n registered in the user config")
|
|
63
|
+
+ git_line,
|
|
64
|
+
)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def list_(args: Any) -> int:
|
|
69
|
+
from brainwiki.index import format_brain_list, list_brains
|
|
70
|
+
|
|
71
|
+
host = getattr(args, "as_host", None)
|
|
72
|
+
result = list_brains(host, explicit=args.brain)
|
|
73
|
+
emit(args, result, format_brain_list(result, owner=host is None))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def brains(args: Any) -> int:
|
|
78
|
+
user = load_user_config()
|
|
79
|
+
action, rest = args.action, args.args
|
|
80
|
+
if action == "list":
|
|
81
|
+
return list_(args)
|
|
82
|
+
if action == "add":
|
|
83
|
+
if len(rest) != 2:
|
|
84
|
+
raise UsageError("brain brains add NAME PATH")
|
|
85
|
+
brain = open_brain(Path(rest[1]))
|
|
86
|
+
register_brain(rest[0], brain.root, make_default=None)
|
|
87
|
+
emit(
|
|
88
|
+
args,
|
|
89
|
+
{"name": rest[0], "path": str(brain.root)},
|
|
90
|
+
f"Registered {rest[0]!r} -> {brain.root}",
|
|
91
|
+
)
|
|
92
|
+
return 0
|
|
93
|
+
if action == "remove":
|
|
94
|
+
if len(rest) != 1:
|
|
95
|
+
raise UsageError("brain brains remove NAME")
|
|
96
|
+
path = user.brains.get(rest[0])
|
|
97
|
+
if not unregister_brain(rest[0]):
|
|
98
|
+
raise NotFound(f"no brain named {rest[0]!r}")
|
|
99
|
+
if args.purge_state and path and (path / "brain.toml").exists():
|
|
100
|
+
from brainwiki.config import cache_dir, state_dir
|
|
101
|
+
|
|
102
|
+
brain_id = open_brain(path).id
|
|
103
|
+
for base in (state_dir(), cache_dir()):
|
|
104
|
+
shutil.rmtree(base / "brains" / brain_id, ignore_errors=True)
|
|
105
|
+
emit(
|
|
106
|
+
args,
|
|
107
|
+
{"removed": rest[0]},
|
|
108
|
+
f"Removed {rest[0]!r} from the user config (the brain folder is untouched)",
|
|
109
|
+
)
|
|
110
|
+
return 0
|
|
111
|
+
if action == "rename":
|
|
112
|
+
if len(rest) != 2 or rest[0] not in user.brains:
|
|
113
|
+
raise UsageError("brain brains rename OLD NEW")
|
|
114
|
+
path = user.brains[rest[0]]
|
|
115
|
+
was_default = user.default == rest[0]
|
|
116
|
+
unregister_brain(rest[0])
|
|
117
|
+
register_brain(rest[1], path, make_default=was_default)
|
|
118
|
+
emit(args, {"renamed": rest}, f"Renamed {rest[0]!r} to {rest[1]!r}")
|
|
119
|
+
return 0
|
|
120
|
+
if action == "default":
|
|
121
|
+
if len(rest) != 1 or rest[0] not in user.brains:
|
|
122
|
+
raise UsageError("brain brains default NAME (a registered brain)")
|
|
123
|
+
set_value("default", rest[0])
|
|
124
|
+
emit(args, {"default": rest[0]}, f"Default brain is now {rest[0]!r}")
|
|
125
|
+
return 0
|
|
126
|
+
raise UsageError(f"unknown action {action}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def doctor(args: Any) -> int:
|
|
130
|
+
from brainwiki.doctor import run_doctor
|
|
131
|
+
|
|
132
|
+
report = run_doctor()
|
|
133
|
+
lines = [f"brain-wiki {report['version']} on {report['device']}"]
|
|
134
|
+
for b in report["brains"]:
|
|
135
|
+
mark = "ok" if b.get("ok") else "PROBLEM"
|
|
136
|
+
lines.append(
|
|
137
|
+
f" brain {b['name']}: {b['path']} [{mark}] notes={b.get('notes', '?')} pending={b.get('pending_episodes', '?')}"
|
|
138
|
+
)
|
|
139
|
+
for h in report["hosts"]:
|
|
140
|
+
if h.get("installed"):
|
|
141
|
+
lines.append(
|
|
142
|
+
f" host {h['id']}: installed, {'connected' if h.get('connected') else 'not connected'}"
|
|
143
|
+
)
|
|
144
|
+
sq = report["sqlite"]
|
|
145
|
+
lines.append(f" sqlite {sq['version']} fts5={sq['fts5']} trigram={sq['trigram']}")
|
|
146
|
+
for p in report["problems"]:
|
|
147
|
+
lines.append(f" ! {p['message']}" + (f" (fix: {p['fix']})" if p.get("fix") else ""))
|
|
148
|
+
emit(args, report, "\n".join(lines))
|
|
149
|
+
return 1 if any(p.get("severity") == "error" for p in report["problems"]) else 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def setup(args: Any) -> int:
|
|
153
|
+
from brainwiki.setup_wizard import run_wizard
|
|
154
|
+
|
|
155
|
+
return run_wizard(args)
|