loopcaptain 0.3.1__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 (50) hide show
  1. loopcaptain/__init__.py +0 -0
  2. loopcaptain/agents.py +162 -0
  3. loopcaptain/board/AGENTS.md +39 -0
  4. loopcaptain/board/__init__.py +0 -0
  5. loopcaptain/board/app.py +430 -0
  6. loopcaptain/board/discover.py +249 -0
  7. loopcaptain/board/files.py +94 -0
  8. loopcaptain/board/filters.py +48 -0
  9. loopcaptain/board/graph.py +139 -0
  10. loopcaptain/board/overview.py +59 -0
  11. loopcaptain/board/static/board.js +41 -0
  12. loopcaptain/board/static/fonts/OFL.txt +94 -0
  13. loopcaptain/board/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  14. loopcaptain/board/static/fonts/inter-tight-latin-wght-normal.woff2 +0 -0
  15. loopcaptain/board/static/mark.svg +5 -0
  16. loopcaptain/board/static/style.css +425 -0
  17. loopcaptain/board/steer.py +207 -0
  18. loopcaptain/board/templates/base.html +27 -0
  19. loopcaptain/board/templates/connect.html +35 -0
  20. loopcaptain/board/templates/file.html +9 -0
  21. loopcaptain/board/templates/loop.html +357 -0
  22. loopcaptain/board/templates/overview.html +108 -0
  23. loopcaptain/board/templates/unreadable.html +17 -0
  24. loopcaptain/board/trend.py +185 -0
  25. loopcaptain/cli.py +204 -0
  26. loopcaptain/demo/__init__.py +59 -0
  27. loopcaptain/demo/loop.py +311 -0
  28. loopcaptain/demo/loops.py +76 -0
  29. loopcaptain/mcp_server/AGENTS.md +23 -0
  30. loopcaptain/mcp_server/__init__.py +0 -0
  31. loopcaptain/mcp_server/server.py +181 -0
  32. loopcaptain/prompt_texts/build-a-loop.md +6 -0
  33. loopcaptain/prompt_texts/watch-my-loops.md +6 -0
  34. loopcaptain/prompts.py +27 -0
  35. loopcaptain/protocol/AGENTS.md +24 -0
  36. loopcaptain/protocol/LOOPS.md +402 -0
  37. loopcaptain/protocol/__init__.py +17 -0
  38. loopcaptain/protocol/doctor.py +79 -0
  39. loopcaptain/protocol/models.py +496 -0
  40. loopcaptain/protocol/signing.py +105 -0
  41. loopcaptain/protocol/state.py +595 -0
  42. loopcaptain/protocol/store.py +275 -0
  43. loopcaptain/protocol/validate.py +199 -0
  44. loopcaptain-0.3.1.dist-info/METADATA +220 -0
  45. loopcaptain-0.3.1.dist-info/RECORD +50 -0
  46. loopcaptain-0.3.1.dist-info/WHEEL +4 -0
  47. loopcaptain-0.3.1.dist-info/entry_points.txt +2 -0
  48. loopcaptain-0.3.1.dist-info/licenses/LICENSE +202 -0
  49. loopcaptain-0.3.1.dist-info/licenses/NOTICE +11 -0
  50. loopcaptain-0.3.1.dist-info/licenses/src/loopcaptain/board/static/fonts/OFL.txt +94 -0
@@ -0,0 +1,185 @@
1
+ """Small charts of a loop's rounds, drawn as SVG on the server: is it converging?
2
+
3
+ No script and no chart library: the content policy allows neither, and a line of a dozen points
4
+ needs neither. Each chart is one series over the rounds, with the loop's target where it gave one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from loopcaptain.protocol.state import LoopState
12
+
13
+ WIDTH = 240
14
+ HEIGHT = 64
15
+ PAD = 6
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Series:
20
+ name: str
21
+ values: tuple[tuple[int, float], ...] # (round number, value), rounds without a value left out
22
+ unit: str = ""
23
+ target: float | None = None
24
+ better: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Dot:
29
+ x: float
30
+ y: float
31
+ round: int
32
+ value: float
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Chart:
37
+ series: Series
38
+ path: str
39
+ dots: tuple[Dot, ...]
40
+ target_y: float | None
41
+ width: int = WIDTH
42
+ height: int = HEIGHT
43
+
44
+ @property
45
+ def last(self) -> float:
46
+ return self.series.values[-1][1]
47
+
48
+ @property
49
+ def first(self) -> float:
50
+ return self.series.values[0][1]
51
+
52
+ @property
53
+ def improving(self) -> bool | None:
54
+ """Whether the last value is better than the first, where "better" is known."""
55
+ if self.series.better is None or len(self.series.values) < 2 or self.last == self.first:
56
+ return None
57
+ return (self.last < self.first) if self.series.better == "lower" else (self.last > self.first)
58
+
59
+
60
+ def series_of(state: LoopState) -> list[Series]:
61
+ """What is worth a line: open material findings, cost (or tokens without a price), every metric."""
62
+ finished = [r for r in state.rounds if not r.running]
63
+ found: list[Series] = []
64
+ if any(run.findings for r in finished for run in r.runs):
65
+ found.append(
66
+ Series(
67
+ "open material findings",
68
+ tuple((r.number, float(sum(run.material_open for run in r.runs))) for r in finished),
69
+ better="lower",
70
+ )
71
+ )
72
+ if any(r.cost_usd for r in finished):
73
+ found.append(Series("cost", tuple((r.number, r.cost_usd) for r in finished), unit="$"))
74
+ elif any(r.tokens for r in finished):
75
+ found.append(Series("tokens", tuple((r.number, float(r.tokens)) for r in finished)))
76
+ names: list[str] = []
77
+ for r in finished:
78
+ names += [metric.name for metric in r.all_metrics if metric.name not in names]
79
+ for name in names:
80
+ points = [(r.number, m) for r in finished for m in r.all_metrics if m.name == name]
81
+ last = points[-1][1]
82
+ found.append(
83
+ Series(name, tuple((n, m.value) for n, m in points), last.unit, last.target, last.better)
84
+ )
85
+ return [series for series in found if series.values]
86
+
87
+
88
+ def chart(series: Series, width: int = WIDTH, height: int = HEIGHT) -> Chart:
89
+ values = [value for _, value in series.values]
90
+ low = min([*values, *([series.target] if series.target is not None else [])])
91
+ high = max([*values, *([series.target] if series.target is not None else [])])
92
+ span = (high - low) or 1.0
93
+ count = len(series.values)
94
+
95
+ def x(index: int) -> float:
96
+ return PAD + (width - 2 * PAD) * (index / (count - 1) if count > 1 else 0.5)
97
+
98
+ def y(value: float) -> float:
99
+ return round(height - PAD - (height - 2 * PAD) * (value - low) / span, 1)
100
+
101
+ dots = tuple(
102
+ Dot(round(x(i), 1), y(value), number, value) for i, (number, value) in enumerate(series.values)
103
+ )
104
+ path = " ".join(f"{'M' if i == 0 else 'L'}{dot.x} {dot.y}" for i, dot in enumerate(dots))
105
+ target_y = y(series.target) if series.target is not None else None
106
+ return Chart(series, path, dots, target_y, width, height)
107
+
108
+
109
+ def charts_of(state: LoopState) -> list[Chart]:
110
+ return [chart(series) for series in series_of(state)]
111
+
112
+
113
+ #: From this share of a cap on, its bar warns.
114
+ WARN_SHARE = 0.8
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class CapBar:
119
+ name: str
120
+ used: str
121
+ cap: str
122
+ share: float # 0..1, clipped
123
+
124
+ @property
125
+ def warns(self) -> bool:
126
+ return self.share >= WARN_SHARE
127
+
128
+
129
+ def caps_of(state: LoopState) -> list[CapBar]:
130
+ """Rounds, runtime and cost against the loop's own caps, for the caps it declares."""
131
+ manifest = state.manifest
132
+ bars = []
133
+ if manifest.round_cap:
134
+ used = state.rounds[-1].number if state.rounds else 0
135
+ bars.append(CapBar("rounds", str(used), str(manifest.round_cap), min(1.0, used / manifest.round_cap)))
136
+ if manifest.runtime_cap_minutes:
137
+ share = state.minutes / manifest.runtime_cap_minutes
138
+ bars.append(
139
+ CapBar("runtime", f"{state.minutes} min", f"{manifest.runtime_cap_minutes} min", min(1.0, share))
140
+ )
141
+ if manifest.cost_cap_usd:
142
+ share = state.cost_usd / manifest.cost_cap_usd
143
+ bars.append(
144
+ CapBar("cost", f"${state.cost_usd:,.2f}", f"${manifest.cost_cap_usd:,.2f}", min(1.0, share))
145
+ )
146
+ return bars
147
+
148
+
149
+ #: How many of a loop's latest rounds the overview draws beside its count.
150
+ TRAIL_ROUNDS = 12
151
+ #: The darkest shade stands for this many open findings or more.
152
+ HEAT_LEVELS = 4
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class TrailCell:
157
+ """One round on the overview: `live` while it runs, `clear` with nothing open, else `h1` to `h4`."""
158
+
159
+ number: int
160
+ look: str
161
+ label: str
162
+
163
+
164
+ def trail_of(state: LoopState) -> list[TrailCell]:
165
+ """The loop's latest rounds, oldest first, each shaded by the findings it left open."""
166
+ cells = []
167
+ for round_ in state.rounds[-TRAIL_ROUNDS:]:
168
+ left_open = round_.findings_open
169
+ if round_.running:
170
+ look, what = "live", "under way"
171
+ elif left_open == 0:
172
+ look, what = "clear", "nothing open"
173
+ else:
174
+ look = f"h{min(left_open, HEAT_LEVELS)}"
175
+ what = f"{left_open} open finding{'s' if left_open != 1 else ''}"
176
+ cells.append(TrailCell(round_.number, look, f"round {round_.number}: {what}"))
177
+ return cells
178
+
179
+
180
+ def figure(value: float, unit: str = "") -> str:
181
+ """A value as a person reads it: whole numbers without decimals, money with two."""
182
+ if unit == "$":
183
+ return f"${value:,.2f}"
184
+ text = f"{value:,.0f}" if float(value).is_integer() else f"{value:,.2f}"
185
+ return f"{text} {unit}".strip()
loopcaptain/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """The `loopcaptain` command: serve the board, check a folder, emit an event, run the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from importlib.metadata import version as package_version
9
+ from pathlib import Path
10
+
11
+ from pydantic import TypeAdapter, ValidationError
12
+
13
+ from loopcaptain.agents import agent, agents, checkout_of, markdown
14
+ from loopcaptain.board.discover import as_written, edit_config
15
+ from loopcaptain.protocol import spec_text, store
16
+ from loopcaptain.protocol.doctor import examine
17
+ from loopcaptain.protocol.models import EVENT_NAMES, AnyEvent, InboxEntry, Manifest, now
18
+ from loopcaptain.protocol.store import StoreError
19
+ from loopcaptain.protocol.validate import validate_report
20
+
21
+ #: This repository's folder, for the MCP command a checkout prints; an installed package has none.
22
+ CHECKOUT = Path(__file__).resolve().parents[2]
23
+
24
+
25
+ def write_schemas(target: Path) -> list[Path]:
26
+ """The protocol as JSON Schema, one file per file of a loop's folder."""
27
+ target.mkdir(parents=True, exist_ok=True)
28
+ schemas = {
29
+ "loop.schema.json": Manifest.model_json_schema(),
30
+ "event.schema.json": TypeAdapter(AnyEvent).json_schema(),
31
+ "inbox.schema.json": InboxEntry.model_json_schema(),
32
+ }
33
+ written = []
34
+ for name, schema in schemas.items():
35
+ path = target / name
36
+ path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
37
+ written.append(path)
38
+ return written
39
+
40
+
41
+ def emit(repo: Path, loop_id: str, event: str, fields: list[str]) -> None:
42
+ """Append one event from `key=value` pairs; a value that parses as JSON is taken as JSON."""
43
+ data: dict[str, object] = {"ts": now().isoformat(), "event": event}
44
+ for pair in fields:
45
+ key, sep, raw = pair.partition("=")
46
+ if not sep:
47
+ raise StoreError(f"{pair!r}: expected key=value")
48
+ try:
49
+ data[key] = json.loads(raw)
50
+ except json.JSONDecodeError:
51
+ data[key] = raw
52
+ try:
53
+ parsed = store.parse_event(data)
54
+ except ValidationError as error:
55
+ raise StoreError(store.short_error(error)) from error
56
+ store.read_manifest(repo, loop_id)
57
+ store.append_event(repo, loop_id, parsed)
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(prog="loopcaptain", description="One board for every agent loop.")
62
+ parser.add_argument(
63
+ "--version", action="version", version=f"loopcaptain {package_version('loopcaptain')}"
64
+ )
65
+ commands = parser.add_subparsers(dest="command", required=True)
66
+
67
+ serve = commands.add_parser("serve", help="the board on 127.0.0.1")
68
+ serve.add_argument("--port", type=int, default=8484, help="the port on 127.0.0.1, default 8484")
69
+
70
+ validate = commands.add_parser("validate", help="check a repository's .loops folders")
71
+ validate.add_argument("repo", type=Path)
72
+ validate.add_argument("--strict", action="store_true", help="count warnings as problems")
73
+
74
+ emit_cmd = commands.add_parser(
75
+ "emit", help="append one event: loopcaptain emit <loop> <event> key=value ..."
76
+ )
77
+ emit_cmd.add_argument("loop")
78
+ emit_cmd.add_argument("event", choices=EVENT_NAMES)
79
+ emit_cmd.add_argument("fields", nargs="*")
80
+ emit_cmd.add_argument("--repo", type=Path, default=Path("."))
81
+
82
+ schema = commands.add_parser("schema", help="write the protocol as JSON Schema")
83
+ schema.add_argument("target", type=Path)
84
+
85
+ doctor = commands.add_parser("doctor", help="send a loop a stop and check that it takes it and ends")
86
+ doctor.add_argument("repo", type=Path)
87
+ doctor.add_argument("loop")
88
+ doctor.add_argument("--timeout", type=float, default=600, help="seconds to wait, default 600")
89
+
90
+ watch = commands.add_parser(
91
+ "watch", help="add a repository, or with --root a folder of them, to the board"
92
+ )
93
+ watch.add_argument("path", type=Path)
94
+ watch.add_argument("--root", action="store_true", help="every sub-folder with .loops/ is a repository")
95
+ unwatch = commands.add_parser("unwatch", help="take a folder off the board")
96
+ unwatch.add_argument("path", type=Path)
97
+
98
+ demo = commands.add_parser(
99
+ "demo", help="write the example loop into a folder and watch it; costs nothing"
100
+ )
101
+ demo.add_argument("--target", type=Path, default=Path("~/.local/share/loopcaptain/demo"))
102
+ demo.add_argument("--no-watch", action="store_true", help="write it, but leave the config alone")
103
+
104
+ mcp_config = commands.add_parser("mcp-config", help="how one coding agent adds the MCP server")
105
+ mcp_config.add_argument("agent", nargs="?", default="", help="claude-code, codex, gemini, cursor, ...")
106
+ mcp_config.add_argument(
107
+ "--checkout", default=checkout_of(CHECKOUT), help="the folder of this repository, if run from one"
108
+ )
109
+ mcp_config.add_argument("--markdown", action="store_true", help="print the page for every agent")
110
+
111
+ commands.add_parser("spec", help="print LOOPS.md, the protocol to paste into a loop")
112
+ commands.add_parser("mcp", help="run the MCP server on stdio")
113
+ return parser
114
+
115
+
116
+ def main(argv: list[str] | None = None) -> int:
117
+ args = build_parser().parse_args(argv)
118
+ try:
119
+ return run(args)
120
+ except StoreError as error:
121
+ sys.stderr.write(f"loopcaptain: {error}\n")
122
+ return 1
123
+
124
+
125
+ def run(args: argparse.Namespace) -> int:
126
+ if args.command == "validate":
127
+ report = validate_report(args.repo.expanduser().resolve())
128
+ problems = report.problems + (report.warnings if args.strict else [])
129
+ warnings = [] if args.strict else report.warnings
130
+ for line in problems:
131
+ sys.stdout.write(f"problem: {line}\n")
132
+ for line in warnings:
133
+ sys.stdout.write(f"warning: {line}\n")
134
+ if problems:
135
+ sys.stdout.write(f"{len(problems)} problem(s), {len(warnings)} warning(s)\n")
136
+ else:
137
+ sys.stdout.write(f"ok, {len(warnings)} warning(s)\n" if warnings else "ok\n")
138
+ return 1 if problems else 0
139
+ if args.command == "emit":
140
+ emit(args.repo.expanduser().resolve(), args.loop, args.event, args.fields)
141
+ return 0
142
+ if args.command == "schema":
143
+ for path in write_schemas(args.target):
144
+ sys.stdout.write(f"{path}\n")
145
+ return 0
146
+ if args.command == "doctor":
147
+ sys.stdout.write("waiting for the loop to take a stop; start it now if it is not running\n")
148
+ checkup = examine(args.repo.expanduser().resolve(), args.loop, args.timeout)
149
+ sys.stdout.write("".join(f"{line}\n" for line in checkup.lines()))
150
+ return 0 if checkup.passed else 1
151
+ if args.command in ("watch", "unwatch"):
152
+ entry = as_written(args.path)
153
+ if args.command == "watch":
154
+ if not args.path.expanduser().is_dir():
155
+ raise StoreError(f"{args.path} is not a folder")
156
+ changes = edit_config(args.path, add=("roots" if args.root else "repos", entry))
157
+ else:
158
+ changes = edit_config(args.path, remove=entry)
159
+ sys.stdout.write("".join(f"{line}\n" for line in changes))
160
+ return 0
161
+ if args.command == "demo":
162
+ from loopcaptain.demo import DemoError, write_demo
163
+
164
+ target = args.target.expanduser()
165
+ try:
166
+ write_demo(target)
167
+ except DemoError as error:
168
+ raise StoreError(str(error)) from error
169
+ sys.stdout.write(f"the example loop is in {target}\n")
170
+ if not args.no_watch:
171
+ sys.stdout.write(
172
+ "".join(f"{line}\n" for line in edit_config(target, add=("repos", as_written(target))))
173
+ )
174
+ sys.stdout.write("start the board with `loopcaptain serve` and open http://127.0.0.1:8484\n")
175
+ return 0
176
+ if args.command == "mcp-config":
177
+ if args.markdown:
178
+ sys.stdout.write(markdown())
179
+ return 0
180
+ if not args.agent:
181
+ sys.stdout.write("".join(f"{item.key:12} {item.name}\n" for item in agents(args.checkout)))
182
+ return 0
183
+ try:
184
+ item = agent(args.agent, args.checkout)
185
+ except KeyError as error:
186
+ raise StoreError(str(error.args[0])) from error
187
+ sys.stdout.write(f"{item.name}\n\n{item.add}\n\nunattended: {item.headless}\n")
188
+ return 0
189
+ if args.command == "spec":
190
+ sys.stdout.write(spec_text())
191
+ return 0
192
+ if args.command == "mcp":
193
+ from loopcaptain.mcp_server.server import serve_stdio
194
+
195
+ serve_stdio()
196
+ return 0
197
+ from loopcaptain.board.app import serve
198
+
199
+ serve(args.port)
200
+ return 0
201
+
202
+
203
+ if __name__ == "__main__":
204
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
1
+ """`loopcaptain demo`: the example loop, written into a folder of its own with its first rounds.
2
+
3
+ The folder gets `loop.py`, the example loop, and `loops.py`, the helper as LOOPS.md prints it: the
4
+ same two files any loop needs. Its history is made by running the loop itself with past times.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ HERE = Path(__file__).resolve().parent
16
+ CHECKOUT_HELPER = HERE.parents[2] / "standard" / "loops.py"
17
+ SEEDED_ROUNDS = 2
18
+ SEED_TIMEOUT_S = 60
19
+ #: What the demo writes into its folder; only these are replaced on a second run.
20
+ WRITTEN = (".loops", "work", "out")
21
+ #: The file that marks a folder as the demo's own: only such a folder is ever cleared.
22
+ MARKER = ".loopcaptain-demo"
23
+
24
+
25
+ class DemoError(Exception):
26
+ """The target is not a folder the demo may write into."""
27
+
28
+
29
+ def helper_source() -> str:
30
+ """The helper: packaged beside this file in a build (force-include), in standard/ in a checkout."""
31
+ packaged = resources.files(__name__).joinpath("loops.py")
32
+ if packaged.is_file():
33
+ return packaged.read_text(encoding="utf-8")
34
+ return CHECKOUT_HELPER.read_text(encoding="utf-8")
35
+
36
+
37
+ def write_demo(target: Path) -> Path:
38
+ """Write the example loop into `target`, replacing what an earlier demo wrote there, and seed it.
39
+
40
+ A folder that holds anything and was not made by the demo is refused: it may be a real repository.
41
+ """
42
+ ours = (target / MARKER).is_file()
43
+ if target.exists() and any(target.iterdir()) and not ours:
44
+ raise DemoError(f"{target} is not empty and was not made by the demo; pick a new folder")
45
+ if ours:
46
+ for name in WRITTEN:
47
+ if (target / name).is_dir() and not (target / name).is_symlink():
48
+ shutil.rmtree(target / name)
49
+ target.mkdir(parents=True, exist_ok=True)
50
+ (target / MARKER).write_text("Written by `loopcaptain demo`; it may clear .loops, work and out here.\n")
51
+ (target / "loops.py").write_text(helper_source(), encoding="utf-8")
52
+ (target / "loop.py").write_text((HERE / "loop.py").read_text(encoding="utf-8"), encoding="utf-8")
53
+ subprocess.run(
54
+ [sys.executable, "loop.py", "--seed", str(SEEDED_ROUNDS)],
55
+ cwd=target,
56
+ check=True,
57
+ timeout=SEED_TIMEOUT_S,
58
+ )
59
+ return target