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,275 @@
1
+ """Reading and writing a loop's folder, `.loops/<loop-id>/` in its repository.
2
+
3
+ The files are the truth; nothing here keeps state. A line the models refuse is reported, never
4
+ fatal: a loop that writes one bad line must not blank its own page.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ import stat
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pydantic import ValidationError
18
+
19
+ from loopcaptain.protocol.models import (
20
+ EVENT,
21
+ EVENTS_FILE,
22
+ FOLDER,
23
+ INBOX_FILE,
24
+ MANIFEST_FILE,
25
+ AnyEvent,
26
+ InboxEntry,
27
+ InboxKind,
28
+ InboxRead,
29
+ Manifest,
30
+ is_slug,
31
+ now,
32
+ )
33
+ from loopcaptain.protocol.signing import sign
34
+
35
+ #: The most the board reads of one file. A summary log of a long loop is a few hundred kilobytes.
36
+ MAX_LOG_BYTES = 20_000_000
37
+ MAX_MANIFEST_BYTES = 1_000_000
38
+
39
+
40
+ class StoreError(Exception):
41
+ """A loop folder that cannot be read or written as asked."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Problem:
46
+ """One thing in a loop's folder the board could not read."""
47
+
48
+ file: str
49
+ line: int # 0 for the whole file
50
+ message: str
51
+
52
+ def __str__(self) -> str:
53
+ where = f"{self.file}:{self.line}" if self.line else self.file
54
+ return f"{where}: {self.message}"
55
+
56
+
57
+ def loop_dir(repo: Path, loop_id: str) -> Path:
58
+ if not is_slug(loop_id):
59
+ raise StoreError(f"{loop_id!r} is not a loop id")
60
+ return repo / FOLDER / loop_id
61
+
62
+
63
+ def speaks_protocol(manifest: Path) -> bool:
64
+ """Whether a loop.json is this protocol's: it carries the `protocol` field.
65
+
66
+ Other tools keep their own files in a folder called `.loops/`; those are skipped quietly.
67
+ """
68
+ try:
69
+ data = json.loads(read_bounded(manifest, MAX_MANIFEST_BYTES))
70
+ except (FileNotFoundError, StoreError, ValueError):
71
+ return False
72
+ return isinstance(data, dict) and "protocol" in data
73
+
74
+
75
+ def loop_ids(repo: Path) -> list[str]:
76
+ """The loops a repository holds: every sub-folder of `.loops/` whose loop.json speaks the protocol."""
77
+ folder = repo / FOLDER
78
+ if folder.is_symlink() or not folder.is_dir():
79
+ return [] # a `.loops` that is a link could show another repository's loops as this one's
80
+ return sorted(
81
+ child.name
82
+ for child in folder.iterdir()
83
+ if is_slug(child.name) and not child.is_symlink() and speaks_protocol(child / MANIFEST_FILE)
84
+ )
85
+
86
+
87
+ def short_error(error: ValidationError) -> str:
88
+ """A validation error in one line, naming the field."""
89
+ parts = []
90
+ for item in error.errors()[:3]:
91
+ where = ".".join(str(part) for part in item["loc"])
92
+ parts.append(f"{where}: {item['msg']}" if where else item["msg"])
93
+ return "; ".join(parts)
94
+
95
+
96
+ # ---- the manifest ----
97
+
98
+
99
+ def read_manifest(repo: Path, loop_id: str) -> Manifest:
100
+ path = loop_dir(repo, loop_id) / MANIFEST_FILE
101
+ try:
102
+ manifest = Manifest.model_validate_json(read_bounded(path, MAX_MANIFEST_BYTES))
103
+ except FileNotFoundError as error:
104
+ raise StoreError(f"{MANIFEST_FILE}: {error.strerror or error}") from error
105
+ except ValidationError as error:
106
+ raise StoreError(f"{MANIFEST_FILE}: {short_error(error)}") from error
107
+ if manifest.id != loop_id:
108
+ raise StoreError(f"{MANIFEST_FILE}: id {manifest.id!r} does not match its folder {loop_id!r}")
109
+ return manifest
110
+
111
+
112
+ def contained(repo: Path, folder: Path, loop_id: str) -> None:
113
+ """Refuse a loop folder that resolves outside its repository, through a link on the way."""
114
+ if not folder.resolve().is_relative_to(repo.resolve()):
115
+ raise StoreError(f"the folder of {loop_id!r} leads out of {repo.name}")
116
+
117
+
118
+ def write_manifest(repo: Path, manifest: Manifest) -> Path:
119
+ """Write `loop.json` whole or not at all: a reader never sees half a file."""
120
+ folder = loop_dir(repo, manifest.id)
121
+ contained(repo, folder, manifest.id)
122
+ folder.mkdir(parents=True, exist_ok=True)
123
+ contained(repo, folder, manifest.id)
124
+ path = folder / MANIFEST_FILE
125
+ tmp = folder / f"{MANIFEST_FILE}.{secrets.token_hex(4)}.tmp"
126
+ tmp.write_text(manifest.model_dump_json(indent=2, exclude_none=True) + "\n", encoding="utf-8")
127
+ os.replace(tmp, path)
128
+ return path
129
+
130
+
131
+ # ---- events and inbox: one JSON object per line ----
132
+
133
+
134
+ def append_line(path: Path, payload: str) -> None:
135
+ """One write call on a file opened for append: lines from two writers do not interleave.
136
+
137
+ A symbolic link is refused: a repository must not be able to point its inbox at another file.
138
+ """
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ try:
141
+ descriptor = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600)
142
+ except OSError as error:
143
+ raise StoreError(f"{path.name}: cannot be written ({error.strerror or error})") from error
144
+ try:
145
+ os.write(descriptor, (payload + "\n").encode("utf-8"))
146
+ finally:
147
+ os.close(descriptor)
148
+
149
+
150
+ def read_bounded(path: Path, limit: int) -> str:
151
+ """A regular file's text, up to `limit` bytes; anything else, a link too, is refused before it is opened.
152
+
153
+ The board reads these files on every request, so a pipe that never ends or a log of gigabytes
154
+ in one repository must not stall or fill it.
155
+ """
156
+ try:
157
+ info = path.lstat()
158
+ except FileNotFoundError:
159
+ raise
160
+ except OSError as error:
161
+ raise StoreError(f"{path.name}: {error.strerror or error}") from error
162
+ if not stat.S_ISREG(info.st_mode):
163
+ raise StoreError(f"{path.name}: not a regular file")
164
+ if info.st_size > limit:
165
+ raise StoreError(
166
+ f"{path.name}: {info.st_size // 1_000_000} MB, over the {limit // 1_000_000} MB the board reads"
167
+ )
168
+ try:
169
+ # Bytes that are not text become a marker, so one bad line is reported and the rest reads.
170
+ return path.read_bytes().decode("utf-8", errors="replace")
171
+ except OSError as error:
172
+ raise StoreError(f"{path.name}: {error.strerror or error}") from error
173
+
174
+
175
+ def complete_lines(path: Path) -> list[tuple[int, str]]:
176
+ """The complete lines of a file with their numbers. A last line still being written is left out."""
177
+ try:
178
+ text = read_bounded(path, MAX_LOG_BYTES)
179
+ except FileNotFoundError:
180
+ return []
181
+ whole = text if text.endswith("\n") else text[: text.rfind("\n") + 1]
182
+ return [(n, line) for n, line in enumerate(whole.splitlines(), start=1) if line.strip()]
183
+
184
+
185
+ def parse_event(data: dict[str, Any]) -> AnyEvent:
186
+ return EVENT.validate_python(data)
187
+
188
+
189
+ def append_event(repo: Path, loop_id: str, event: AnyEvent) -> None:
190
+ append_line(loop_dir(repo, loop_id) / EVENTS_FILE, event.model_dump_json(exclude_none=True))
191
+
192
+
193
+ def read_events(repo: Path, loop_id: str) -> tuple[list[AnyEvent], list[Problem]]:
194
+ events: list[AnyEvent] = []
195
+ problems: list[Problem] = []
196
+ for number, line in complete_lines(loop_dir(repo, loop_id) / EVENTS_FILE):
197
+ try:
198
+ events.append(EVENT.validate_json(line))
199
+ except ValidationError as error:
200
+ problems.append(Problem(EVENTS_FILE, number, short_error(error)))
201
+ return events, problems
202
+
203
+
204
+ def append_inbox(
205
+ repo: Path,
206
+ loop_id: str,
207
+ kind: InboxKind,
208
+ text: str = "",
209
+ round_: int | None = None,
210
+ key: bytes | None = None,
211
+ question: str | None = None,
212
+ choice: str | None = None,
213
+ ) -> InboxEntry:
214
+ """What the person sends, signed with `key` when given. The loop's folder must exist: the board
215
+ never creates a loop."""
216
+ folder = loop_dir(repo, loop_id)
217
+ if not (folder / MANIFEST_FILE).is_file():
218
+ raise StoreError(f"no loop {loop_id!r} in {repo.name}")
219
+ contained(repo, folder, loop_id)
220
+ stamp = now()
221
+ entry = InboxEntry(
222
+ id=f"{stamp:%Y%m%dT%H%M%S}-{secrets.token_hex(2)}",
223
+ ts=stamp,
224
+ kind=kind,
225
+ text=text.strip(),
226
+ round=round_,
227
+ question=question,
228
+ choice=choice,
229
+ loop=loop_id,
230
+ )
231
+ if key is not None:
232
+ entry = entry.model_copy(update={"sig": sign(entry.body(), key)})
233
+ append_line(folder / INBOX_FILE, entry.model_dump_json(exclude_none=True))
234
+ return entry
235
+
236
+
237
+ def read_inbox(repo: Path, loop_id: str) -> tuple[list[InboxEntry], list[Problem]]:
238
+ entries: list[InboxEntry] = []
239
+ problems: list[Problem] = []
240
+ for number, line in complete_lines(loop_dir(repo, loop_id) / INBOX_FILE):
241
+ try:
242
+ entries.append(InboxEntry.model_validate_json(line))
243
+ except ValidationError as error:
244
+ problems.append(Problem(INBOX_FILE, number, short_error(error)))
245
+ return entries, problems
246
+
247
+
248
+ def take_inbox(repo: Path, loop_id: str) -> list[InboxEntry]:
249
+ """What the person sent that the loop has not taken yet, oldest first; marks it read.
250
+
251
+ The loop's side of the inbox: it appends `inbox_read` to the loop's own events.
252
+ """
253
+ events, _ = read_events(repo, loop_id)
254
+ taken = {entry_id for event in events if isinstance(event, InboxRead) for entry_id in event.ids}
255
+ entries, _ = read_inbox(repo, loop_id)
256
+ fresh = [entry for entry in entries if entry.id not in taken]
257
+ if fresh:
258
+ append_event(repo, loop_id, InboxRead(ts=now(), ids=[entry.id for entry in fresh]))
259
+ return fresh
260
+
261
+
262
+ def fingerprint(repo: Path, loop_id: str) -> str:
263
+ """Changes whenever any of the loop's files does; the page polls it to know when to reload."""
264
+ parts = []
265
+ for name in (MANIFEST_FILE, EVENTS_FILE, INBOX_FILE):
266
+ try:
267
+ stat = (loop_dir(repo, loop_id) / name).stat()
268
+ parts.append(f"{stat.st_mtime_ns}-{stat.st_size}")
269
+ except OSError:
270
+ parts.append("0")
271
+ return ".".join(parts)
272
+
273
+
274
+ def dump(data: object) -> str:
275
+ return json.dumps(data, ensure_ascii=False)
@@ -0,0 +1,199 @@
1
+ """`loopcaptain validate`: check a repository's `.loops` folders against the protocol.
2
+
3
+ A problem breaks the text of LOOPS.md or makes the board show something false; a warning is a sign
4
+ that a loop is wired in a way that hides what really happens (a round written after the fact, an
5
+ inbox nobody reads). `--strict` counts warnings as problems.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from loopcaptain.protocol import store
15
+ from loopcaptain.protocol.models import (
16
+ FOLDER,
17
+ MANIFEST_FILE,
18
+ AnyEvent,
19
+ InboxEntry,
20
+ InboxRead,
21
+ Manifest,
22
+ RoundFinished,
23
+ RoundStarted,
24
+ StepFinished,
25
+ StepStarted,
26
+ )
27
+ from loopcaptain.protocol.store import StoreError
28
+
29
+
30
+ @dataclass
31
+ class Report:
32
+ """What `validate` found, problems and warnings apart, one line each."""
33
+
34
+ problems: list[str] = field(default_factory=list)
35
+ warnings: list[str] = field(default_factory=list)
36
+
37
+ def extend(self, prefix: str, other: Report) -> None:
38
+ self.problems.extend(f"{prefix}{line}" for line in other.problems)
39
+ self.warnings.extend(f"{prefix}{line}" for line in other.warnings)
40
+
41
+
42
+ def validate_repo(repo: Path) -> list[str]:
43
+ """Every problem found, one line each; empty when the folders are sound."""
44
+ return validate_report(repo).problems
45
+
46
+
47
+ def validate_report(repo: Path) -> Report:
48
+ """Problems and warnings of every loop in a repository.
49
+
50
+ Only folders whose loop.json carries the `protocol` field are loops; anything else another
51
+ tool keeps under the same name is not this protocol's business.
52
+ """
53
+ folder = repo / FOLDER
54
+ if not folder.is_dir():
55
+ return Report(problems=[f"{repo}: no {FOLDER} folder"])
56
+ loops = store.loop_ids(repo)
57
+ if not loops:
58
+ return Report(
59
+ problems=[
60
+ f"{FOLDER}: no loop inside "
61
+ f"(a loop is a sub-folder whose {MANIFEST_FILE} has a protocol field)"
62
+ ]
63
+ )
64
+ report = Report()
65
+ for loop_id in loops:
66
+ report.extend(f"{loop_id}/", validate_loop(repo, loop_id))
67
+ return report
68
+
69
+
70
+ def validate_loop(repo: Path, loop_id: str) -> Report:
71
+ try:
72
+ manifest = store.read_manifest(repo, loop_id)
73
+ events, event_problems = store.read_events(repo, loop_id)
74
+ inbox, inbox_problems = store.read_inbox(repo, loop_id)
75
+ except StoreError as error:
76
+ return Report(problems=[str(error)])
77
+ report = Report(problems=[str(problem) for problem in (*event_problems, *inbox_problems)])
78
+ report.problems += order_problems(manifest, events)
79
+ report.problems += missing_paths(repo, manifest, events)
80
+ report.problems += time_problems(events)
81
+ report.problems += verdict_problems(events)
82
+ report.warnings += back_produced(events)
83
+ report.warnings += missing_outputs(repo, manifest)
84
+ report.warnings += unread_inbox(events, inbox)
85
+ report.warnings += restarted_count(events)
86
+ return Report(list(dict.fromkeys(report.problems)), list(dict.fromkeys(report.warnings)))
87
+
88
+
89
+ def unread_inbox(events: list[AnyEvent], inbox: list[InboxEntry]) -> list[str]:
90
+ """Entries the person sent before a round began that the loop never took: it does not listen."""
91
+ taken = {entry_id for event in events if isinstance(event, InboxRead) for entry_id in event.ids}
92
+ starts = [event.ts for event in events if isinstance(event, RoundStarted)]
93
+ missed = [entry for entry in inbox if entry.id not in taken and starts and entry.ts < max(starts)]
94
+ if not missed:
95
+ return []
96
+ count = f"{len(missed)} entr{'y' if len(missed) == 1 else 'ies'}"
97
+ return [f"inbox.jsonl: {count} sent before a round began and never taken; does the loop read it?"]
98
+
99
+
100
+ def order_problems(manifest: Manifest, events: list[AnyEvent]) -> list[str]:
101
+ declared = {step.id for step in manifest.steps}
102
+ started: set[int] = set()
103
+ problems: list[str] = []
104
+ unknown: list[str] = []
105
+ for event in events:
106
+ if isinstance(event, RoundStarted):
107
+ started.add(event.round)
108
+ elif isinstance(event, RoundFinished):
109
+ if event.round not in started:
110
+ problems.append(f"events.jsonl: round {event.round} finished without round_started")
111
+ elif isinstance(event, StepStarted | StepFinished):
112
+ if event.round not in started:
113
+ problems.append(
114
+ f"events.jsonl: step {event.step!r} reported for round {event.round} before it started"
115
+ )
116
+ if declared and event.step not in declared and event.step not in unknown:
117
+ unknown.append(event.step)
118
+ return problems + [f"events.jsonl: step {step!r} is not in loop.json" for step in unknown]
119
+
120
+
121
+ def missing_paths(repo: Path, manifest: Manifest, events: list[AnyEvent]) -> list[str]:
122
+ """Paths the loop names as written that are not there: the board would link to nothing."""
123
+ problems = [
124
+ f"loop.json: {path} does not exist"
125
+ for path in (manifest.brief.path, *(step.prompt for step in manifest.steps))
126
+ if path and not (repo / path).exists()
127
+ ]
128
+ for event in events:
129
+ if isinstance(event, StepFinished) and event.artifact and not (repo / event.artifact).exists():
130
+ problems.append(
131
+ f"events.jsonl: round {event.round} step {event.step!r}: {event.artifact} does not exist"
132
+ )
133
+ return problems
134
+
135
+
136
+ def missing_outputs(repo: Path, manifest: Manifest) -> list[str]:
137
+ """Outputs may not exist before the loop is done, so a missing one is a warning."""
138
+ return [
139
+ f"loop.json: output {output.name!r} at {output.path} does not exist yet"
140
+ for output in manifest.outputs
141
+ if not (repo / output.path).exists()
142
+ ]
143
+
144
+
145
+ def time_problems(events: list[AnyEvent]) -> list[str]:
146
+ problems = []
147
+ latest: datetime | None = None
148
+ for number, event in enumerate(events, start=1):
149
+ if latest and event.ts < latest:
150
+ problems.append(f"events.jsonl: event {number} ({event.event}) is earlier than the one before it")
151
+ latest = max(latest, event.ts) if latest else event.ts
152
+ return problems
153
+
154
+
155
+ def verdict_problems(events: list[AnyEvent]) -> list[str]:
156
+ verdicts: dict[int, str] = {}
157
+ problems = []
158
+ for event in events:
159
+ if not isinstance(event, RoundFinished):
160
+ continue
161
+ earlier = verdicts.get(event.round)
162
+ verdicts[event.round] = event.verdict
163
+ # `wait` is a pause, not an outcome: a round that waited may finish again.
164
+ if earlier and earlier != "wait" and earlier != event.verdict:
165
+ problems.append(
166
+ f"events.jsonl: round {event.round} finished twice, as {earlier} and as {event.verdict}"
167
+ )
168
+ return problems
169
+
170
+
171
+ def back_produced(events: list[AnyEvent]) -> list[str]:
172
+ """A round whose steps all carry one time was written after the fact: its durations are invented."""
173
+ times: dict[int, set[datetime]] = {}
174
+ counts: dict[int, int] = {}
175
+ for event in events:
176
+ if isinstance(event, StepFinished):
177
+ times.setdefault(event.round, set()).add(event.when)
178
+ counts[event.round] = counts.get(event.round, 0) + 1
179
+ return [
180
+ f"events.jsonl: round {number}: all {counts[number]} steps share one time; written after the fact?"
181
+ for number, stamps in sorted(times.items())
182
+ if counts[number] > 1 and len(stamps) == 1
183
+ ]
184
+
185
+
186
+ def restarted_count(events: list[AnyEvent]) -> list[str]:
187
+ """A round started below one already started: a second run counting from the start in one folder."""
188
+ highest = 0
189
+ warnings = []
190
+ for event in events:
191
+ if not isinstance(event, RoundStarted):
192
+ continue
193
+ if event.round < highest:
194
+ warnings.append(
195
+ f"events.jsonl: round {event.round} started after round {highest}; "
196
+ "a new run? give each run its own loop id"
197
+ )
198
+ highest = max(highest, event.round)
199
+ return warnings