brain-wiki 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. brain_wiki-0.1.0.dev0.dist-info/METADATA +231 -0
  2. brain_wiki-0.1.0.dev0.dist-info/RECORD +141 -0
  3. brain_wiki-0.1.0.dev0.dist-info/WHEEL +4 -0
  4. brain_wiki-0.1.0.dev0.dist-info/entry_points.txt +4 -0
  5. brain_wiki-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  6. brainwiki/__init__.py +17 -0
  7. brainwiki/__main__.py +5 -0
  8. brainwiki/assets/plugin/.claude-plugin/marketplace.json +16 -0
  9. brainwiki/assets/plugin/.claude-plugin/plugin.json +19 -0
  10. brainwiki/assets/plugin/.mcp.json +16 -0
  11. brainwiki/assets/plugin/mcp.json +17 -0
  12. brainwiki/assets/plugin/mcp_config.json +15 -0
  13. brainwiki/assets/plugin/plugin.json +37 -0
  14. brainwiki/assets/plugin/rules/brain-wiki.md +30 -0
  15. brainwiki/assets/plugin/server.json +30 -0
  16. brainwiki/assets/plugin/skills/brain/SKILL.md +145 -0
  17. brainwiki/assets/plugin/skills/brain/references/format.md +149 -0
  18. brainwiki/assets/plugin/skills/brain-maintain/SKILL.md +111 -0
  19. brainwiki/assets/plugin/skills/brain-maintain/references/consolidate.md +91 -0
  20. brainwiki/assets/plugin/skills/brain-maintain/references/harvest.md +144 -0
  21. brainwiki/assets/plugin/skills/brain-setup/SKILL.md +184 -0
  22. brainwiki/assets/plugin/skills/brain-setup/references/hosts.md +88 -0
  23. brainwiki/assets/plugin/skills/brain-setup/references/privacy.md +67 -0
  24. brainwiki/assets/prompts/consolidate.md +89 -0
  25. brainwiki/assets/prompts/distill.md +96 -0
  26. brainwiki/assets/prompts/harvest.md +142 -0
  27. brainwiki/bench/__init__.py +8 -0
  28. brainwiki/bench/_generate.py +440 -0
  29. brainwiki/bench/_report.py +166 -0
  30. brainwiki/bench/_suites.py +959 -0
  31. brainwiki/cli/__init__.py +7 -0
  32. brainwiki/cli/_cmd_bench.py +101 -0
  33. brainwiki/cli/_cmd_browse.py +281 -0
  34. brainwiki/cli/_cmd_consolidate.py +108 -0
  35. brainwiki/cli/_cmd_core.py +155 -0
  36. brainwiki/cli/_cmd_harvest.py +137 -0
  37. brainwiki/cli/_cmd_hosts.py +52 -0
  38. brainwiki/cli/_cmd_import.py +149 -0
  39. brainwiki/cli/_cmd_lint.py +117 -0
  40. brainwiki/cli/_cmd_read.py +131 -0
  41. brainwiki/cli/_cmd_runs.py +66 -0
  42. brainwiki/cli/_cmd_viz.py +57 -0
  43. brainwiki/cli/_cmd_write.py +122 -0
  44. brainwiki/cli/_main.py +511 -0
  45. brainwiki/cli/_output.py +19 -0
  46. brainwiki/config/__init__.py +56 -0
  47. brainwiki/config/_dirs.py +69 -0
  48. brainwiki/config/_manifest.py +307 -0
  49. brainwiki/config/_resolve.py +169 -0
  50. brainwiki/config/_user.py +183 -0
  51. brainwiki/consolidate/__init__.py +19 -0
  52. brainwiki/consolidate/_apply.py +408 -0
  53. brainwiki/consolidate/_packet.py +489 -0
  54. brainwiki/consolidate/_pipeline.py +215 -0
  55. brainwiki/consolidate/_presets.py +230 -0
  56. brainwiki/consolidate/_review.py +104 -0
  57. brainwiki/consolidate/_schema.py +153 -0
  58. brainwiki/consolidate/_validate.py +252 -0
  59. brainwiki/doctor.py +216 -0
  60. brainwiki/engine.py +80 -0
  61. brainwiki/errors.py +81 -0
  62. brainwiki/format/__init__.py +112 -0
  63. brainwiki/format/_agents_md.py +26 -0
  64. brainwiki/format/_claims.py +95 -0
  65. brainwiki/format/_document.py +203 -0
  66. brainwiki/format/_edit.py +94 -0
  67. brainwiki/format/_episode.py +67 -0
  68. brainwiki/format/_fields.py +350 -0
  69. brainwiki/format/_frontmatter.py +175 -0
  70. brainwiki/format/_links.py +72 -0
  71. brainwiki/format/_meta.py +67 -0
  72. brainwiki/format/_normalize.py +70 -0
  73. brainwiki/format/_relations.py +211 -0
  74. brainwiki/format/_rules.py +41 -0
  75. brainwiki/harvest.py +414 -0
  76. brainwiki/hosts/__init__.py +22 -0
  77. brainwiki/hosts/_antigravity.py +163 -0
  78. brainwiki/hosts/_claude_code.py +174 -0
  79. brainwiki/hosts/_codex.py +149 -0
  80. brainwiki/hosts/_connect.py +443 -0
  81. brainwiki/hosts/_detect.py +39 -0
  82. brainwiki/hosts/_instructions.py +220 -0
  83. brainwiki/hosts/_sessions.py +176 -0
  84. brainwiki/ids.py +69 -0
  85. brainwiki/importer/__init__.py +17 -0
  86. brainwiki/importer/_episodes.py +112 -0
  87. brainwiki/importer/_into.py +102 -0
  88. brainwiki/importer/_normalize.py +64 -0
  89. brainwiki/importer/_overlay.py +199 -0
  90. brainwiki/importer/_survey.py +167 -0
  91. brainwiki/index/__init__.py +44 -0
  92. brainwiki/index/_brains.py +119 -0
  93. brainwiki/index/_db.py +131 -0
  94. brainwiki/index/_guards.py +154 -0
  95. brainwiki/index/_neighbors.py +174 -0
  96. brainwiki/index/_orient.py +400 -0
  97. brainwiki/index/_read.py +285 -0
  98. brainwiki/index/_resolver.py +147 -0
  99. brainwiki/index/_scanner.py +665 -0
  100. brainwiki/index/_schema.py +164 -0
  101. brainwiki/index/_search.py +659 -0
  102. brainwiki/index/_topics.py +253 -0
  103. brainwiki/index/_written.py +29 -0
  104. brainwiki/lifecycle.py +158 -0
  105. brainwiki/lint/__init__.py +20 -0
  106. brainwiki/lint/_catalog.py +85 -0
  107. brainwiki/lint/_cross.py +649 -0
  108. brainwiki/lint/_files.py +483 -0
  109. brainwiki/lint/_fixes.py +87 -0
  110. brainwiki/lint/_hook.py +54 -0
  111. brainwiki/lint/_model.py +103 -0
  112. brainwiki/lint/_reports.py +242 -0
  113. brainwiki/lint/_runner.py +156 -0
  114. brainwiki/logs.py +34 -0
  115. brainwiki/mcp/__init__.py +7 -0
  116. brainwiki/mcp/_server.py +917 -0
  117. brainwiki/origin.py +97 -0
  118. brainwiki/runs/__init__.py +21 -0
  119. brainwiki/runs/_git.py +89 -0
  120. brainwiki/runs/_lock.py +42 -0
  121. brainwiki/runs/_record.py +145 -0
  122. brainwiki/runs/_transaction.py +441 -0
  123. brainwiki/runs/_undo.py +159 -0
  124. brainwiki/setup_wizard.py +143 -0
  125. brainwiki/store/__init__.py +7 -0
  126. brainwiki/store/_local.py +153 -0
  127. brainwiki/viz/__init__.py +9 -0
  128. brainwiki/viz/_export.py +414 -0
  129. brainwiki/viz/_lod.py +455 -0
  130. brainwiki/viz/_server.py +327 -0
  131. brainwiki/viz/app/BUILD_INFO.json +5 -0
  132. brainwiki/viz/app/THIRD_PARTY_NOTICES.txt +1519 -0
  133. brainwiki/viz/app/assets/index-gK1pnQdO.js +4496 -0
  134. brainwiki/viz/app/assets/style-Cg8NUCst.css +1 -0
  135. brainwiki/viz/app/index.html +14 -0
  136. brainwiki/write/__init__.py +23 -0
  137. brainwiki/write/_edits.py +730 -0
  138. brainwiki/write/_episode.py +195 -0
  139. brainwiki/write/_move.py +181 -0
  140. brainwiki/write/_rules.py +204 -0
  141. brainwiki/write/_secrets.py +108 -0
@@ -0,0 +1,137 @@
1
+ """harvest, origin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from importlib import resources
7
+ from typing import Any
8
+
9
+ from brainwiki.cli._output import emit, warn
10
+ from brainwiki.errors import UsageError
11
+
12
+
13
+ def harvest(args: Any) -> int:
14
+ from brainwiki import harvest as hv
15
+ from brainwiki.config import load_user_config
16
+
17
+ user = load_user_config()
18
+ hosts = args.hosts or list(user.harvest.get("hosts") or ["claude-code", "codex", "antigravity"])
19
+ candidates = hv.discover(user, hosts, since=args.since, project=args.project)
20
+ eligible = [c for c in candidates if not c.skip]
21
+ if args.status:
22
+ rows = []
23
+ for host in hosts:
24
+ mine = [c for c in candidates if c.info.host == host]
25
+ rows.append(
26
+ {
27
+ "host": host,
28
+ "enabled": user.harvest_enabled(host),
29
+ "sessions": len(mine),
30
+ "unharvested": sum(1 for c in mine if not c.skip),
31
+ }
32
+ )
33
+ text = "\n".join(
34
+ f"{r['host']}: {'on' if r['enabled'] else 'off'}, {r['unharvested']} of {r['sessions']} sessions to harvest"
35
+ for r in rows
36
+ )
37
+ if not all(r["enabled"] for r in rows):
38
+ text += (
39
+ f"\nTo turn harvest on, add to {user.path}:\n"
40
+ '[harvest]\nenabled = true\nhosts = ["claude-code", "codex", "antigravity"]'
41
+ )
42
+ emit(args, rows, text)
43
+ return 0
44
+ if not args.apply:
45
+ data = [c.to_json() for c in candidates]
46
+ lines = [
47
+ f"{c.info.host} {c.info.id[:12]} → {c.brain.display_name if c.brain else '-'} "
48
+ + (
49
+ f"skip: {c.skip}"
50
+ if c.skip
51
+ else f"turns {c.to_json()['turns']}, ~{c.to_json()['estimated_tokens']} tokens"
52
+ )
53
+ for c in candidates
54
+ ]
55
+ total = sum(c.to_json()["estimated_tokens"] for c in eligible)
56
+ if any(c.skip == "routes to no brain" for c in candidates):
57
+ lines.append(
58
+ "Sessions route to the brain of their working directory: put a .brain-wiki.toml "
59
+ 'with `brain = "NAME"` in the project, or map folders in [harvest.routes] in '
60
+ f"{user.path}."
61
+ )
62
+ lines.append(
63
+ f"{len(eligible)} sessions to harvest, ~{total} tokens to distill."
64
+ + (" Run with --apply." if eligible else "")
65
+ )
66
+ emit(args, data, "\n".join(lines))
67
+ return 0
68
+ preset = None if args.raw else (args.distill or user.harvest.get("distill") or "claude-code")
69
+ if preset == "raw":
70
+ preset = None
71
+ results = [_harvest_one(hv, c, preset, dry_run=args.dry_run) for c in eligible]
72
+ written = [p for r in results for p in r["episodes"]]
73
+ emit(args, results, f"Wrote {len(written)} episodes from {len(eligible)} sessions.")
74
+ return 0 if all(not r.get("error") for r in results) else 1
75
+
76
+
77
+ def _harvest_one(hv: Any, candidate: Any, preset: str | None, *, dry_run: bool) -> dict[str, Any]:
78
+ from brainwiki.consolidate import run_command
79
+
80
+ out: dict[str, Any] = {
81
+ "session": candidate.info.id,
82
+ "host": candidate.info.host,
83
+ "episodes": [],
84
+ }
85
+ prompt_head = (resources.files("brainwiki") / "assets" / "prompts" / "distill.md").read_text(
86
+ encoding="utf-8"
87
+ )
88
+ for group in hv.chunks(candidate.new_turns):
89
+ first, last = group[0].index, group[-1].index
90
+ if dry_run:
91
+ out["episodes"].append(f"(dry run) turns {first}-{last}")
92
+ continue
93
+ if preset is None:
94
+ out["episodes"].append(
95
+ hv.write_episode_for(candidate, first, last, None, raw_turns=group)
96
+ )
97
+ hv.advance(candidate.info.host, candidate.info.id, last)
98
+ continue
99
+ turns, truncated = hv.condense(group, hv.CHUNK_CHARS)
100
+ payload = json.dumps(hv.session_payload(candidate, turns, truncated), ensure_ascii=False)
101
+ try:
102
+ reply = run_command(preset, prompt_head + "\n" + payload + "\n")
103
+ except Exception as exc: # noqa: BLE001 - report per session and continue
104
+ out["error"] = str(exc)
105
+ warn(f"{candidate.info.id}: {exc}")
106
+ return out
107
+ data = _first_json(reply)
108
+ distilled, problems = hv.parse_distilled(data, first, last)
109
+ if problems:
110
+ out["error"] = "; ".join(problems)
111
+ return out
112
+ if distilled is not None and not distilled.skip:
113
+ out["episodes"].append(hv.write_episode_for(candidate, first, last, distilled))
114
+ hv.advance(candidate.info.host, candidate.info.id, last)
115
+ return out
116
+
117
+
118
+ def _first_json(text: str) -> Any:
119
+ decoder = json.JSONDecoder()
120
+ for start in [i for i, ch in enumerate(text) if ch == "{"]:
121
+ try:
122
+ value, _ = decoder.raw_decode(text[start:])
123
+ except ValueError:
124
+ continue
125
+ if isinstance(value, dict):
126
+ return value
127
+ raise UsageError("the distill reply contained no JSON object")
128
+
129
+
130
+ def origin(args: Any) -> int:
131
+ from brainwiki.engine import open_engine
132
+ from brainwiki.origin import format_origin, origin_window
133
+
134
+ with open_engine(args.brain, host=getattr(args, "as_host", None)) as engine:
135
+ result = origin_window(engine, args.episode, turns=args.turns)
136
+ emit(args, result, format_origin(result))
137
+ return 0
@@ -0,0 +1,52 @@
1
+ """connect, mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def mcp(args: Any) -> int:
9
+ from brainwiki.mcp import serve
10
+
11
+ serve(args.host, args.brain)
12
+ return 0
13
+
14
+
15
+ def connect(args: Any) -> int:
16
+ from brainwiki.cli._output import emit
17
+ from brainwiki.errors import UsageError
18
+ from brainwiki.hosts import connect as cn
19
+
20
+ if args.uninstall:
21
+ steps = cn.uninstall(args.host, dry_run=args.dry_run)
22
+ emit(args, {"host": args.host, "steps": steps}, "\n".join(steps))
23
+ return 0
24
+ if args.print_only or args.host == "generic":
25
+ if args.host not in cn.HOSTS:
26
+ raise UsageError(f"unknown host {args.host!r}; use one of {', '.join(cn.HOSTS)}")
27
+ text = cn.print_text(args.host, args.brain)
28
+ emit(args, {"text": text}, text)
29
+ return 0
30
+ plan = cn.plan(args.host, scope=args.scope, brain_name=args.brain, force=args.force)
31
+ if args.dry_run:
32
+ emit(args, {"plan": plan.describe()}, plan.describe())
33
+ return 0
34
+ record = cn.apply(plan, brain_name=args.brain, scope=args.scope)
35
+ failed = [c for c in record["commands"] if not c["ok"]]
36
+ lines = [f"Connected {args.host}: plugin at {record['plugin_dir']}"]
37
+ lines += [
38
+ f" instructions: {item['written']}"
39
+ for item in record.get("instructions", [])
40
+ if not item.get("plugin")
41
+ ]
42
+ lines += [f" failed: {' '.join(c['run'])}: {c['output'].strip()}" for c in failed]
43
+ lines += plan.notes
44
+ if "visible" in record:
45
+ lines.append(
46
+ " verified: brain-wiki is listed"
47
+ if record["visible"]
48
+ else " warning: brain-wiki is not listed yet"
49
+ )
50
+ lines.append(f"Undo with: brain connect {args.host} --uninstall")
51
+ emit(args, record, "\n".join(lines))
52
+ return 1 if failed else 0
@@ -0,0 +1,149 @@
1
+ """import."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from brainwiki.cli._output import emit
9
+ from brainwiki.errors import NotFound, UsageError
10
+
11
+
12
+ def import_(args: Any) -> int:
13
+ if args.from_host:
14
+ return _from_host(args)
15
+ if not args.path:
16
+ raise UsageError("give a folder or file to import, or --from HOST")
17
+ source = Path(args.path).expanduser()
18
+ if args.overlay:
19
+ return _overlay(args, source)
20
+ if args.into:
21
+ return _into(args, source)
22
+ if args.to_episodes:
23
+ return _to_episodes(args, source)
24
+ raise UsageError("choose a mode: --overlay, --into BRAIN, --to-episodes, or --from HOST")
25
+
26
+
27
+ def _overlay(args: Any, source: Path) -> int:
28
+ from brainwiki.importer import overlay
29
+
30
+ if args.phase == 0:
31
+ if not args.apply:
32
+ from brainwiki.importer import survey
33
+
34
+ found = survey(source)
35
+ text = overlay.report(found, args.brain or source.resolve().name)
36
+ emit(
37
+ args,
38
+ found.to_json(),
39
+ text + "\nNothing changed. Run with --apply to adopt (phase 0).",
40
+ )
41
+ return 0
42
+ result = overlay.adopt(source, name=args.brain)
43
+ emit(
44
+ args,
45
+ result,
46
+ f"Adopted {result['root']} as brain {result['brain']!r}. Next: --phase 1, 2, 3 (each optional).",
47
+ )
48
+ return 0
49
+ from brainwiki.config import open_brain
50
+ from brainwiki.engine import Engine
51
+
52
+ with Engine(open_brain(source)) as engine:
53
+ record, diff = overlay.apply_phase(engine, args.phase, dry_run=not args.apply)
54
+ if not args.apply:
55
+ emit(
56
+ args,
57
+ {"diff": diff},
58
+ (diff or "Nothing to change.") + ("\n\nRun with --apply." if diff else ""),
59
+ )
60
+ return 0
61
+ if record is None:
62
+ emit(args, {"run": None}, "Nothing to change.")
63
+ return 0
64
+ emit(
65
+ args,
66
+ record.to_json(),
67
+ f"Phase {args.phase} applied ({record.run}); undo with brain undo {record.run}",
68
+ )
69
+ return 0
70
+
71
+
72
+ def _into(args: Any, source: Path) -> int:
73
+ from brainwiki.engine import open_engine
74
+ from brainwiki.importer import plan_into, tidy_after_import
75
+ from brainwiki.lint import run_gate
76
+
77
+ with open_engine(args.into) as engine:
78
+ run, summary = plan_into(engine, source, topic=args.topic)
79
+ if not args.apply:
80
+ lines = [
81
+ f"Would copy {summary['files']} files into topics {', '.join(summary['topics'])}."
82
+ ]
83
+ lines.append(
84
+ f"Markdown links to files outside the source: {summary['unresolved_md_links']}"
85
+ )
86
+ emit(args, summary, "\n".join(lines) + "\nNothing changed. Run with --apply.")
87
+ return 0
88
+ record = run.commit(
89
+ check=run_gate(engine), log_lines=[f"- imported {summary['files']} files from {source}"]
90
+ )
91
+ tidy = [r.run for r in tidy_after_import(engine)]
92
+ text = f"Imported {summary['files']} files ({record.run})."
93
+ if tidy:
94
+ text += f" Then added hubs and refreshed AGENTS.md ({', '.join(tidy)}); undo all to revert."
95
+ emit(args, record.to_json() | summary | {"tidy_runs": tidy}, text)
96
+ return 0
97
+
98
+
99
+ def _to_episodes(args: Any, source: Path) -> int:
100
+ from brainwiki.engine import open_engine
101
+ from brainwiki.importer import import_documents
102
+
103
+ if not source.exists():
104
+ raise NotFound(f"no such file or folder: {source}")
105
+ paths = sorted(source.rglob("*.md")) if source.is_dir() else [source]
106
+ with open_engine(args.brain, host=getattr(args, "as_host", None)) as engine:
107
+ results = import_documents(
108
+ engine,
109
+ paths,
110
+ base=source if source.is_dir() else source.parent,
111
+ origin="import",
112
+ inputs="external" if args.untrusted else "local",
113
+ agent=args.agent,
114
+ topic=args.topic,
115
+ dry_run=not args.apply,
116
+ )
117
+ failed = [r["file"] for r in results if r.get("error")]
118
+ written = len(results) - len(failed)
119
+ verb = "Would write" if not args.apply else "Wrote"
120
+ text = f"{verb} {written} episodes from {len(paths) - len(failed)} files."
121
+ if failed:
122
+ text += f" Could not read {len(failed)}: " + ", ".join(failed)
123
+ emit(args, results, text + ("" if args.apply else " Run with --apply."))
124
+ return 1 if failed else 0
125
+
126
+
127
+ def _from_host(args: Any) -> int:
128
+ from brainwiki.engine import open_engine
129
+ from brainwiki.importer import import_documents, memory_files
130
+
131
+ override = Path(args.path).expanduser() if args.path else None
132
+ files = memory_files(args.from_host, override)
133
+ with open_engine(args.brain) as engine:
134
+ results = import_documents(
135
+ engine,
136
+ files,
137
+ base=Path.home(),
138
+ origin="agent",
139
+ inputs="unknown",
140
+ agent=args.from_host,
141
+ topic=args.topic,
142
+ dry_run=not args.apply,
143
+ )
144
+ lines = [f"{args.from_host}: {len(files)} memory files, {len(results)} episodes"]
145
+ lines += [f" {f}" for f in files]
146
+ if not args.apply:
147
+ lines.append("Nothing changed. Run with --apply.")
148
+ emit(args, results, "\n".join(lines))
149
+ return 0
@@ -0,0 +1,117 @@
1
+ """lint, report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from brainwiki.cli._output import emit
10
+ from brainwiki.engine import Engine, open_engine
11
+ from brainwiki.errors import NotFound
12
+
13
+
14
+ def _engine(args: Any) -> Engine:
15
+ return open_engine(args.brain, host=getattr(args, "as_host", None))
16
+
17
+
18
+ def _codes(raw: str | None) -> list[str] | None:
19
+ return [c.strip().upper() for c in raw.split(",") if c.strip()] if raw else None
20
+
21
+
22
+ def brain_paths(engine: Engine, raw: list[str]) -> list[str] | None:
23
+ """CLI paths (absolute, cwd-relative, or brain-relative) as brain-relative paths."""
24
+ if not raw:
25
+ return None
26
+ out = []
27
+ for item in raw:
28
+ candidate = Path(item) if Path(item).is_absolute() else Path.cwd() / item
29
+ try:
30
+ rel = candidate.resolve().relative_to(engine.brain.root).as_posix()
31
+ except ValueError:
32
+ rel = item.replace("\\", "/").strip("/")
33
+ out.append("" if rel == "." else rel)
34
+ return out
35
+
36
+
37
+ def _changed_paths(engine: Engine) -> list[str]:
38
+ row = engine.db.execute("SELECT value FROM meta WHERE key='last_lint_ns'").fetchone()
39
+ since = int(row["value"]) if row else 0
40
+ return [
41
+ r["path"] for r in engine.db.execute("SELECT path FROM files WHERE mtime_ns > ?", (since,))
42
+ ]
43
+
44
+
45
+ def _mark_linted(engine: Engine) -> None:
46
+ engine.db.execute(
47
+ "INSERT OR REPLACE INTO meta(key, value) VALUES ('last_lint_ns', ?)", (str(time.time_ns()),)
48
+ )
49
+
50
+
51
+ def lint(args: Any) -> int:
52
+ from brainwiki.lint import lint as run_lint
53
+
54
+ with _engine(args) as engine:
55
+ if args.install_hook:
56
+ from brainwiki.lint import install_hook
57
+
58
+ target = install_hook(engine.brain.root)
59
+ emit(args, {"installed": str(target)}, f"Installed {target}")
60
+ return 0
61
+ paths = brain_paths(engine, args.paths)
62
+ for raw, rel in zip(args.paths or [], paths or [], strict=True):
63
+ if not (engine.brain.root / rel).exists():
64
+ raise NotFound(f"no such file or folder in the brain: {raw}")
65
+ select, ignore = _codes(args.select), _codes(args.ignore)
66
+ if args.fix:
67
+ from brainwiki.lint import fix
68
+
69
+ outcome = fix(
70
+ engine,
71
+ paths=paths,
72
+ profile=args.profile,
73
+ select=select,
74
+ ignore=ignore,
75
+ dry_run=args.dry_run,
76
+ )
77
+ if args.dry_run:
78
+ emit(
79
+ args,
80
+ {"fixes": [p.to_json() for p in outcome.applied], "diff": outcome.diff},
81
+ outcome.diff or "Nothing to fix.",
82
+ )
83
+ return 0
84
+ data = outcome.remaining.to_json()
85
+ data["fixed"] = [p.to_json() for p in outcome.applied]
86
+ data["run"] = outcome.record.run if outcome.record else None
87
+ text = (
88
+ f"Fixed {len(outcome.applied)} problem(s) in {outcome.record.run} (undo: brain undo {outcome.record.run})\n"
89
+ if outcome.record
90
+ else "Nothing to fix.\n"
91
+ ) + outcome.remaining.format_text()
92
+ emit(args, data, text)
93
+ return 1 if outcome.remaining.errors else 0
94
+ staged = None
95
+ if args.staged:
96
+ from brainwiki.lint import staged_files
97
+
98
+ staged = staged_files(engine.brain.root)
99
+ elif args.changed:
100
+ engine.scan()
101
+ paths = _changed_paths(engine)
102
+ result = run_lint(
103
+ engine, paths=paths, profile=args.profile, select=select, ignore=ignore, staged=staged
104
+ )
105
+ if staged is None and (args.changed or paths is None):
106
+ _mark_linted(engine)
107
+ emit(args, result.to_json(), result.format_text())
108
+ return 1 if result.errors else 0
109
+
110
+
111
+ def report(args: Any) -> int:
112
+ from brainwiki.lint import generate
113
+
114
+ with _engine(args) as engine:
115
+ written = generate(engine)
116
+ emit(args, {"written": written}, "\n".join(f"wrote {p}" for p in written))
117
+ return 0
@@ -0,0 +1,131 @@
1
+ """orient, search, read, neighbors, index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from brainwiki.cli._output import emit
8
+ from brainwiki.engine import open_engine
9
+ from brainwiki.errors import UsageError
10
+
11
+
12
+ def _engine(args: Any):
13
+ return open_engine(args.brain, host=getattr(args, "as_host", None))
14
+
15
+
16
+ def orient(args: Any) -> int:
17
+ from brainwiki.index import orient as run
18
+
19
+ with _engine(args) as engine:
20
+ result = run(engine)
21
+ emit(args, result, result["text"])
22
+ return 0
23
+
24
+
25
+ def search(args: Any) -> int:
26
+ from brainwiki.index import format_search_text
27
+ from brainwiki.index import search as run
28
+
29
+ include = {part.strip() for part in (args.include or "").split(",") if part.strip()}
30
+ with _engine(args) as engine:
31
+ result = run(
32
+ engine,
33
+ args.query,
34
+ limit=args.limit,
35
+ topics=args.topic,
36
+ types=args.types,
37
+ tags=args.tags,
38
+ where=_parse_where(args.where),
39
+ since=args.since,
40
+ until=args.until,
41
+ sort=args.sort,
42
+ cursor=args.cursor,
43
+ count_only=args.count,
44
+ facets=args.facets,
45
+ intent=args.intent,
46
+ include_episodes=True
47
+ if "episodes" in include
48
+ else (False if args.no_episodes else None),
49
+ include_archived="archived" in include,
50
+ include_archived_episodes="archived_episodes" in include,
51
+ )
52
+ if args.count and not args.json and not args.facets:
53
+ print(result["total"])
54
+ return 0
55
+ emit(args, result, format_search_text(result, "paths" if args.paths else "text"))
56
+ return 0
57
+
58
+
59
+ def _parse_where(items: list[str]) -> dict[str, str]:
60
+ where = {}
61
+ for item in items or []:
62
+ key, sep, value = item.partition("=")
63
+ if not sep or not key.strip():
64
+ raise UsageError(f"--where takes KEY=VALUE, not {item!r}")
65
+ where[key.strip()] = value.strip()
66
+ return where
67
+
68
+
69
+ def read(args: Any) -> int:
70
+ from brainwiki.index import format_read_text
71
+ from brainwiki.index import read as run
72
+
73
+ with _engine(args) as engine:
74
+ result = run(engine, args.target, mode=args.mode, section=args.section)
75
+ emit(args, result, format_read_text(result))
76
+ return 0
77
+
78
+
79
+ def neighbors(args: Any) -> int:
80
+ from brainwiki.index import format_neighbors_text
81
+ from brainwiki.index import neighbors as run
82
+
83
+ with _engine(args) as engine:
84
+ result = run(
85
+ engine,
86
+ args.target,
87
+ relations=args.relations,
88
+ direction=args.direction,
89
+ depth=args.depth,
90
+ limit=args.limit,
91
+ )
92
+ text = (
93
+ "\n".join(n["path"] for n in result["nodes"][1:])
94
+ if args.paths
95
+ else format_neighbors_text(result)
96
+ )
97
+ emit(args, result, text)
98
+ return 0
99
+
100
+
101
+ def index(args: Any) -> int:
102
+ with _engine(args) as engine:
103
+ stats = engine.rebuild() if args.rebuild else engine.scan(force=True)
104
+ counts = {
105
+ row["kind"]: row["c"]
106
+ for row in engine.db.execute("SELECT kind, COUNT(*) AS c FROM files GROUP BY kind")
107
+ }
108
+ edges = engine.db.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
109
+ unresolved = engine.db.execute("SELECT COUNT(*) FROM edges WHERE other IS NULL").fetchone()[
110
+ 0
111
+ ]
112
+ data = {
113
+ "files": counts,
114
+ "edges": edges,
115
+ "unresolved_links": unresolved,
116
+ "scan": {
117
+ "added": stats.added,
118
+ "changed": stats.changed,
119
+ "removed": stats.removed,
120
+ "seconds": round(stats.seconds, 3),
121
+ },
122
+ }
123
+ text = (
124
+ "Index "
125
+ + ("rebuilt" if args.rebuild else "up to date")
126
+ + f" in {stats.seconds:.2f}s: "
127
+ + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
128
+ + f"; edges={edges} (unresolved {unresolved})"
129
+ )
130
+ emit(args, data, text)
131
+ return 0
@@ -0,0 +1,66 @@
1
+ """runs, undo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from brainwiki.cli._output import emit
8
+ from brainwiki.engine import open_engine
9
+
10
+
11
+ def runs(args: Any) -> int:
12
+ from brainwiki.runs import list_records, load_record
13
+
14
+ with open_engine(args.brain, host=getattr(args, "as_host", None)) as engine:
15
+ key = engine.brain.key
16
+ if args.action == "show":
17
+ record = load_record(key, args.run) if args.run else list_records(key)[0]
18
+ data = record.to_json()
19
+ lines = [
20
+ f"{record.run} {record.kind} {record.status} {record.started}",
21
+ f"agent: {record.agent or 'owner'}"
22
+ + (f" backend: {record.backend}" if record.backend else ""),
23
+ ]
24
+ if record.summary:
25
+ lines.append(record.summary)
26
+ for change in record.files:
27
+ if change.moved_to:
28
+ lines.append(f" {change.path} -> {change.moved_to}")
29
+ elif change.before is None:
30
+ lines.append(f" + {change.path}")
31
+ elif change.after is None:
32
+ lines.append(f" - {change.path}")
33
+ else:
34
+ lines.append(f" ~ {change.path}")
35
+ if record.git_commit:
36
+ lines.append(f"git: {record.git_commit[:12]}")
37
+ if record.git_error:
38
+ lines.append(f"git error: {record.git_error}")
39
+ if record.undone_by:
40
+ lines.append(f"undone by {record.undone_by}")
41
+ emit(args, data, "\n".join(lines))
42
+ return 0
43
+ records = list_records(key)
44
+ data = [
45
+ {
46
+ k: r.to_json()[k]
47
+ for k in ("run", "kind", "status", "started", "agent", "summary", "undone_by")
48
+ }
49
+ for r in records
50
+ ]
51
+ text = "\n".join(
52
+ f"{r.run} {r.kind:<12} {r.status:<11} {r.started} {r.summary}"
53
+ + (f" (undone by {r.undone_by})" if r.undone_by else "")
54
+ for r in records
55
+ )
56
+ emit(args, data, text or "No runs on this device.")
57
+ return 0
58
+
59
+
60
+ def undo(args: Any) -> int:
61
+ from brainwiki.runs import undo as run_undo
62
+
63
+ with open_engine(args.brain, host=getattr(args, "as_host", None)) as engine:
64
+ result = run_undo(engine, args.run, force=args.force, agent=getattr(args, "as_host", None))
65
+ emit(args, result.to_json(), f"{result.summary} ({result.run})")
66
+ return 0