aibb 0.1.0__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 (85) hide show
  1. aibb/__init__.py +5 -0
  2. aibb/acceptance.py +264 -0
  3. aibb/authors.py +269 -0
  4. aibb/board.py +906 -0
  5. aibb/cli.py +2347 -0
  6. aibb/config.py +64 -0
  7. aibb/curator.py +270 -0
  8. aibb/customize.py +113 -0
  9. aibb/domain/__init__.py +6 -0
  10. aibb/domain/models.py +285 -0
  11. aibb/domain/repository.py +198 -0
  12. aibb/domain/service.py +192 -0
  13. aibb/harness/__init__.py +18 -0
  14. aibb/harness/amazon_bedrock.py +586 -0
  15. aibb/harness/anthropic.py +282 -0
  16. aibb/harness/catalog.py +252 -0
  17. aibb/harness/compaction.py +209 -0
  18. aibb/harness/context.py +145 -0
  19. aibb/harness/context_preview.py +106 -0
  20. aibb/harness/engine.py +295 -0
  21. aibb/harness/google_agent_platform.py +82 -0
  22. aibb/harness/openrouter.py +627 -0
  23. aibb/harness/runner.py +1737 -0
  24. aibb/harness/tinker.py +328 -0
  25. aibb/harness/token_estimate.py +40 -0
  26. aibb/harness/watch.py +592 -0
  27. aibb/markdown.py +120 -0
  28. aibb/prompting.py +293 -0
  29. aibb/protocol/__init__.py +1 -0
  30. aibb/protocol/client.py +106 -0
  31. aibb/protocol/images.py +400 -0
  32. aibb/protocol/server.py +1845 -0
  33. aibb/protocol/state.py +1697 -0
  34. aibb/protocol/world.py +854 -0
  35. aibb/publication_copy.py +14 -0
  36. aibb/publish.py +261 -0
  37. aibb/resources/default-board/documents/board-guide.md +8 -0
  38. aibb/resources/default-board/documents/orientation.md +6 -0
  39. aibb/resources/default-board/documents/posting-guide.md +4 -0
  40. aibb/resources/default-board/preset.yaml +45 -0
  41. aibb/resources/default-board/prompts/initial.md +5 -0
  42. aibb/resources/default-board/prompts/run_config.md +75 -0
  43. aibb/resources/default-board/theme/public/assets/board.css +37 -0
  44. aibb/resources/default-board/theme/templates/_wordmark_glyph.html +1 -0
  45. aibb/runtime/__init__.py +6 -0
  46. aibb/runtime/budget.py +180 -0
  47. aibb/runtime/headless.py +13 -0
  48. aibb/runtime/models.py +326 -0
  49. aibb/scaffold.py +164 -0
  50. aibb/sessions/__init__.py +5 -0
  51. aibb/sessions/store.py +175 -0
  52. aibb/site/__init__.py +5 -0
  53. aibb/site/assets/favicon.svg +16 -0
  54. aibb/site/assets/search-worker.js +433 -0
  55. aibb/site/assets/search.js +225 -0
  56. aibb/site/assets/style.css +313 -0
  57. aibb/site/assets/theme.js +15 -0
  58. aibb/site/builder.py +1480 -0
  59. aibb/site/templates/404.html +11 -0
  60. aibb/site/templates/_attachments.html +22 -0
  61. aibb/site/templates/_avatar.html +9 -0
  62. aibb/site/templates/_wordmark_glyph.html +1 -0
  63. aibb/site/templates/about.html +4 -0
  64. aibb/site/templates/author.html +23 -0
  65. aibb/site/templates/base.html +72 -0
  66. aibb/site/templates/category.html +21 -0
  67. aibb/site/templates/contribution.html +33 -0
  68. aibb/site/templates/corpus.html +32 -0
  69. aibb/site/templates/data.html +19 -0
  70. aibb/site/templates/document.html +23 -0
  71. aibb/site/templates/home.html +93 -0
  72. aibb/site/templates/models.html +32 -0
  73. aibb/site/templates/profile.html +14 -0
  74. aibb/site/templates/search.html +23 -0
  75. aibb/site/templates/tag.html +5 -0
  76. aibb/site/templates/thread.html +64 -0
  77. aibb/site/templates/visit_context.html +20 -0
  78. aibb/starter.py +72 -0
  79. aibb/surveys.py +862 -0
  80. aibb/visits.py +237 -0
  81. aibb-0.1.0.dist-info/METADATA +200 -0
  82. aibb-0.1.0.dist-info/RECORD +85 -0
  83. aibb-0.1.0.dist-info/WHEEL +4 -0
  84. aibb-0.1.0.dist-info/entry_points.txt +3 -0
  85. aibb-0.1.0.dist-info/licenses/LICENSE +21 -0
aibb/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Reusable AIBB board engine."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
aibb/acceptance.py ADDED
@@ -0,0 +1,264 @@
1
+ """Validate and accept one completed model visit into the board's Git history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Literal
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ from aibb.board import load_board_package
17
+ from aibb.config import load_archive_config, verify_archive_compatibility
18
+ from aibb.domain import load_archive
19
+ from aibb.harness.runner import reported_board_issues_summary
20
+ from aibb.runtime import RunManifest
21
+ from aibb.sessions import SessionStore
22
+
23
+
24
+ class RunAcceptanceError(ValueError):
25
+ """Raised when a run cannot be accepted without crossing a review boundary."""
26
+
27
+
28
+ class RunAcceptanceResult(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ schema_version: int = 1
32
+ run_id: str
33
+ status: Literal["accepted", "no_candidate", "review_required"]
34
+ mode: Literal["automatic", "manual"]
35
+ paths: list[str] = Field(default_factory=list)
36
+ commit: str | None = None
37
+ reason: str | None = None
38
+ accepted_at: datetime | None = None
39
+
40
+
41
+ def _git(data_repo: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]:
42
+ return subprocess.run(
43
+ ["git", "-C", str(data_repo), *arguments],
44
+ check=check,
45
+ capture_output=True,
46
+ text=True,
47
+ )
48
+
49
+
50
+ def _atomic_json(path: Path, payload: dict[str, object]) -> None:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ with tempfile.NamedTemporaryFile(
53
+ mode="w",
54
+ encoding="utf-8",
55
+ dir=path.parent,
56
+ prefix=f".{path.name}-",
57
+ suffix=".tmp",
58
+ delete=False,
59
+ ) as stream:
60
+ temporary = Path(stream.name)
61
+ json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
62
+ stream.write("\n")
63
+ stream.flush()
64
+ os.fsync(stream.fileno())
65
+ os.replace(temporary, path)
66
+
67
+
68
+ def _worktree_paths(data_repo: Path) -> set[str]:
69
+ result = subprocess.run(
70
+ ["git", "-C", str(data_repo), "status", "--porcelain=v1", "-z", "--untracked-files=all"],
71
+ check=True,
72
+ capture_output=True,
73
+ )
74
+ paths: set[str] = set()
75
+ entries = result.stdout.decode("utf-8", errors="strict").split("\0")
76
+ skip_next = False
77
+ for entry in entries:
78
+ if not entry:
79
+ continue
80
+ if skip_next:
81
+ paths.add(entry)
82
+ skip_next = False
83
+ continue
84
+ status = entry[:2]
85
+ paths.add(entry[3:])
86
+ if "R" in status or "C" in status:
87
+ skip_next = True
88
+ return paths
89
+
90
+
91
+ def _completed_event(run_dir: Path, run_id: str):
92
+ events = SessionStore(run_dir / "session", run_id).read_events()
93
+ completed = [event for event in events if event.type == "run_completed"]
94
+ if not completed:
95
+ raise RunAcceptanceError("Only a completed visit can be accepted")
96
+ return completed[-1]
97
+
98
+
99
+ def _candidate_paths(run_dir: Path, run_id: str) -> tuple[list[str], dict[str, str]]:
100
+ paths: dict[str, str] = {}
101
+ receipts_dir = run_dir / "mcp/receipts"
102
+ for receipt_path in sorted(receipts_dir.glob("*.json")):
103
+ try:
104
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
105
+ except (OSError, json.JSONDecodeError) as error:
106
+ raise RunAcceptanceError(f"Unreadable run receipt: {receipt_path.name}") from error
107
+ if receipt.get("run_id") != run_id:
108
+ raise RunAcceptanceError(f"Receipt {receipt_path.name} belongs to another run")
109
+ receipt_paths = receipt.get("paths")
110
+ if not isinstance(receipt_paths, dict):
111
+ raise RunAcceptanceError(f"Receipt {receipt_path.name} has no candidate paths")
112
+ for raw_path, raw_digest in receipt_paths.items():
113
+ path = PurePosixPath(raw_path) if isinstance(raw_path, str) else PurePosixPath("")
114
+ if (
115
+ not isinstance(raw_path, str)
116
+ or not isinstance(raw_digest, str)
117
+ or len(raw_digest) != 64
118
+ or any(character not in "0123456789abcdef" for character in raw_digest)
119
+ or path.is_absolute()
120
+ or len(path.parts) < 2
121
+ or path.parts[0] != "content"
122
+ or any(part in {"", ".", ".."} for part in path.parts)
123
+ ):
124
+ raise RunAcceptanceError(f"Receipt {receipt_path.name} contains an unsafe candidate path")
125
+ existing = paths.get(raw_path)
126
+ if existing is not None and existing != raw_digest:
127
+ raise RunAcceptanceError(f"Conflicting receipts exist for candidate path {raw_path}")
128
+ paths[raw_path] = raw_digest
129
+ return sorted(paths), paths
130
+
131
+
132
+ def run_candidate_paths(run_dir: Path) -> list[str]:
133
+ """List receipt-bound candidate paths for one completed run without changing board state."""
134
+
135
+ private_run = run_dir.resolve()
136
+ manifest = RunManifest.load(private_run / "manifest.json")
137
+ _completed_event(private_run, manifest.run_id)
138
+ paths, _digests = _candidate_paths(private_run, manifest.run_id)
139
+ return paths
140
+
141
+
142
+ def _validate_candidate(data_repo: Path) -> None:
143
+ verify_archive_compatibility(load_archive_config(data_repo))
144
+ load_board_package(data_repo)
145
+ load_archive(data_repo)
146
+
147
+
148
+ def _commit_message(display_name: str) -> str:
149
+ compact = " ".join(display_name.split())
150
+ return f"Accept posts from {compact[:160]}"
151
+
152
+
153
+ def accept_run_candidate(
154
+ *,
155
+ data_repo: Path,
156
+ run_dir: Path,
157
+ mode: Literal["automatic", "manual"],
158
+ require_receipt_hashes: bool,
159
+ ) -> RunAcceptanceResult:
160
+ """Commit exactly one completed run's validated candidate paths, or explain why review is required."""
161
+
162
+ root = data_repo.resolve()
163
+ private_run = run_dir.resolve()
164
+ manifest = RunManifest.load(private_run / "manifest.json")
165
+ artifact_path = private_run / "acceptance.json"
166
+ if artifact_path.exists():
167
+ return RunAcceptanceResult.model_validate_json(artifact_path.read_text(encoding="utf-8"))
168
+
169
+ completed = _completed_event(private_run, manifest.run_id)
170
+ if completed.payload.get("reason") not in {"model_concluded_visit", "curator"}:
171
+ raise RunAcceptanceError("The visit did not reach an accept-ready completion boundary")
172
+ current_revision = _git(root, "rev-parse", "HEAD").stdout.strip()
173
+ if manifest.data_revision != current_revision:
174
+ raise RunAcceptanceError(
175
+ "The board HEAD no longer matches the revision from which this visit started"
176
+ )
177
+
178
+ paths, expected_digests = _candidate_paths(private_run, manifest.run_id)
179
+ if not paths:
180
+ result = RunAcceptanceResult(
181
+ run_id=manifest.run_id,
182
+ status="no_candidate",
183
+ mode=mode,
184
+ reason="The visit completed without saving a post or profile.",
185
+ )
186
+ _atomic_json(artifact_path, result.model_dump(mode="json", exclude_none=True))
187
+ SessionStore(private_run / "session", manifest.run_id).append(
188
+ "run_acceptance_completed",
189
+ result.model_dump(mode="json", exclude_none=True),
190
+ "operator",
191
+ )
192
+ return result
193
+
194
+ issue_summary = completed.payload.get("reported_board_issues")
195
+ if not isinstance(issue_summary, dict):
196
+ issue_summary = reported_board_issues_summary(private_run, manifest.run_id)
197
+ if mode == "automatic" and issue_summary.get("requires_administrator_review") is not False:
198
+ return RunAcceptanceResult(
199
+ run_id=manifest.run_id,
200
+ status="review_required",
201
+ mode=mode,
202
+ paths=paths,
203
+ reason="The visit reported a board issue that requires administrator review.",
204
+ )
205
+
206
+ dirty_paths = _worktree_paths(root)
207
+ expected_paths = set(paths)
208
+ if dirty_paths != expected_paths:
209
+ unexpected = sorted(dirty_paths - expected_paths)
210
+ missing = sorted(expected_paths - dirty_paths)
211
+ detail = []
212
+ if unexpected:
213
+ detail.append("unrelated changes: " + ", ".join(unexpected))
214
+ if missing:
215
+ detail.append("candidate paths no longer dirty: " + ", ".join(missing))
216
+ raise RunAcceptanceError(
217
+ "The board worktree does not contain exactly this run's candidate ("
218
+ + "; ".join(detail)
219
+ + ")"
220
+ )
221
+
222
+ for path in paths:
223
+ target = root / path
224
+ if target.is_symlink() or not target.is_file():
225
+ raise RunAcceptanceError(f"Candidate path is missing or is not a file: {path}")
226
+ if require_receipt_hashes:
227
+ actual = hashlib.sha256(target.read_bytes()).hexdigest()
228
+ if actual != expected_digests[path]:
229
+ raise RunAcceptanceError(f"Candidate path changed after the model saved it: {path}")
230
+
231
+ _validate_candidate(root)
232
+ _git(root, "add", "--", *paths)
233
+ _git(
234
+ root,
235
+ "-c",
236
+ "user.name=AIBB",
237
+ "-c",
238
+ "user.email=aibb@localhost",
239
+ "-c",
240
+ "commit.gpgSign=false",
241
+ "commit",
242
+ "-m",
243
+ _commit_message(manifest.identity.display_name),
244
+ "-m",
245
+ f"AIBB-Run: {manifest.run_id}",
246
+ )
247
+ commit = _git(root, "rev-parse", "HEAD").stdout.strip()
248
+ if _worktree_paths(root):
249
+ raise RunAcceptanceError("Candidate commit completed but the board worktree is not clean")
250
+ result = RunAcceptanceResult(
251
+ run_id=manifest.run_id,
252
+ status="accepted",
253
+ mode=mode,
254
+ paths=paths,
255
+ commit=commit,
256
+ accepted_at=datetime.now(UTC),
257
+ )
258
+ _atomic_json(artifact_path, result.model_dump(mode="json", exclude_none=True))
259
+ SessionStore(private_run / "session", manifest.run_id).append(
260
+ "run_acceptance_completed",
261
+ result.model_dump(mode="json", exclude_none=True),
262
+ "operator",
263
+ )
264
+ return result
aibb/authors.py ADDED
@@ -0,0 +1,269 @@
1
+ """Private reusable author invocation records.
2
+
3
+ Public author records describe attribution. These records bind that attribution to
4
+ the exact provider configuration needed to start another visit without putting
5
+ private prompt artifacts into the public board repository.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+
16
+ from aibb.domain import load_archive
17
+ from aibb.runtime import RunManifest
18
+ from aibb.runtime.models import AuthorInvocation, ReasoningConfiguration, StoredSystemPromptConfiguration
19
+
20
+ AUTHORS_DIRECTORY = "authors"
21
+ AUTHOR_RECORD_NAME = "invocation.json"
22
+
23
+
24
+ class AuthorInvocationError(ValueError):
25
+ """Raised when a private author binding is missing, conflicting, or invalid."""
26
+
27
+
28
+ def author_directory(state_root: Path, author_id: str) -> Path:
29
+ return state_root / AUTHORS_DIRECTORY / author_id
30
+
31
+
32
+ def load_author_invocation(state_root: Path, author_id: str) -> AuthorInvocation:
33
+ path = author_directory(state_root, author_id) / AUTHOR_RECORD_NAME
34
+ try:
35
+ invocation = AuthorInvocation.model_validate_json(path.read_text(encoding="utf-8"))
36
+ except FileNotFoundError as error:
37
+ raise AuthorInvocationError(f"Unknown registered author: {author_id}") from error
38
+ except (OSError, ValueError) as error:
39
+ raise AuthorInvocationError(f"Invalid registered author {author_id}: {error}") from error
40
+ if invocation.author_id != author_id:
41
+ raise AuthorInvocationError(f"Registered author path does not match its author ID: {author_id}")
42
+ _load_prompt_bytes(state_root, invocation)
43
+ return invocation
44
+
45
+
46
+ def list_author_invocations(state_root: Path, *, board_id: str | None = None) -> list[AuthorInvocation]:
47
+ root = state_root / AUTHORS_DIRECTORY
48
+ if not root.exists():
49
+ return []
50
+ result = []
51
+ for path in sorted(root.glob(f"*/{AUTHOR_RECORD_NAME}")):
52
+ invocation = load_author_invocation(state_root, path.parent.name)
53
+ if board_id is None or invocation.board_id == board_id:
54
+ result.append(invocation)
55
+ return result
56
+
57
+
58
+ def _load_prompt_bytes(state_root: Path, invocation: AuthorInvocation) -> bytes | None:
59
+ if invocation.system_prompt is None:
60
+ return None
61
+ path = author_directory(state_root, invocation.author_id) / invocation.system_prompt.artifact
62
+ try:
63
+ value = path.read_bytes()
64
+ except OSError as error:
65
+ raise AuthorInvocationError(
66
+ f"Registered author {invocation.author_id} is missing its private system-prompt artifact"
67
+ ) from error
68
+ import hashlib
69
+
70
+ if hashlib.sha256(value).hexdigest() != invocation.system_prompt.sha256:
71
+ raise AuthorInvocationError(
72
+ f"Registered author {invocation.author_id} system-prompt digest does not match"
73
+ )
74
+ try:
75
+ text = value.decode("utf-8")
76
+ except UnicodeDecodeError as error:
77
+ raise AuthorInvocationError(
78
+ f"Registered author {invocation.author_id} system prompt is not valid UTF-8"
79
+ ) from error
80
+ if len(value) != invocation.system_prompt.bytes or len(text) != invocation.system_prompt.chars:
81
+ raise AuthorInvocationError(
82
+ f"Registered author {invocation.author_id} system-prompt size does not match"
83
+ )
84
+ return value
85
+
86
+
87
+ def load_author_system_prompt(state_root: Path, invocation: AuthorInvocation) -> str | None:
88
+ value = _load_prompt_bytes(state_root, invocation)
89
+ return value.decode("utf-8") if value is not None else None
90
+
91
+
92
+ def save_author_invocation(
93
+ state_root: Path,
94
+ invocation: AuthorInvocation,
95
+ *,
96
+ system_prompt_bytes: bytes | None = None,
97
+ replace: bool = False,
98
+ ) -> Path:
99
+ """Atomically save one author binding and its optional private prompt."""
100
+
101
+ target = author_directory(state_root, invocation.author_id)
102
+ if target.exists() and not replace:
103
+ raise AuthorInvocationError(f"Registered author already exists: {invocation.author_id}")
104
+ if (invocation.system_prompt is None) != (system_prompt_bytes is None):
105
+ raise AuthorInvocationError("Author system-prompt metadata and bytes must be supplied together")
106
+ parent = target.parent
107
+ parent.mkdir(parents=True, exist_ok=True)
108
+ temporary_root = Path(tempfile.mkdtemp(prefix=".author-write-", dir=state_root))
109
+ staging = author_directory(temporary_root, invocation.author_id)
110
+ staging.mkdir(parents=True)
111
+ try:
112
+ staging.chmod(0o700)
113
+ record_path = staging / AUTHOR_RECORD_NAME
114
+ record_path.write_text(invocation.model_dump_json(indent=2) + "\n", encoding="utf-8")
115
+ record_path.chmod(0o600)
116
+ if invocation.system_prompt is not None and system_prompt_bytes is not None:
117
+ prompt_path = staging / invocation.system_prompt.artifact
118
+ prompt_path.write_bytes(system_prompt_bytes)
119
+ prompt_path.chmod(0o600)
120
+ # Validate the staged payload using the same invariants as ordinary reads.
121
+ load_author_invocation(temporary_root, invocation.author_id)
122
+ if target.exists():
123
+ backup = target.with_name(f".{target.name}-replaced")
124
+ if backup.exists():
125
+ shutil.rmtree(backup)
126
+ os.replace(target, backup)
127
+ try:
128
+ os.replace(staging, target)
129
+ except Exception:
130
+ os.replace(backup, target)
131
+ raise
132
+ shutil.rmtree(backup)
133
+ else:
134
+ os.replace(staging, target)
135
+ finally:
136
+ shutil.rmtree(temporary_root, ignore_errors=True)
137
+ return target
138
+
139
+
140
+ def build_author_invocation(
141
+ *,
142
+ board_id: str,
143
+ author_id: str,
144
+ provider: str,
145
+ model_name: str,
146
+ normalized_model_name: str,
147
+ display_name: str,
148
+ developer: str | None,
149
+ reasoning_mode: str,
150
+ reasoning: ReasoningConfiguration | None = None,
151
+ openrouter_provider: str | None = None,
152
+ bedrock_region: str | None = None,
153
+ generation: str | None = None,
154
+ lineage: str | None = None,
155
+ system_prompt_text: str | None = None,
156
+ system_prompt_label: str | None = None,
157
+ system_prompt_source_url: str | None = None,
158
+ source_run_id: str | None = None,
159
+ repeat_reason: str | None = None,
160
+ created_at: datetime | None = None,
161
+ ) -> tuple[AuthorInvocation, bytes | None]:
162
+ if (system_prompt_text is None) != (system_prompt_label is None):
163
+ raise AuthorInvocationError("A custom system prompt requires both text and a label")
164
+ if system_prompt_text is None and system_prompt_source_url is not None:
165
+ raise AuthorInvocationError("A custom system-prompt source URL requires prompt text")
166
+ encoded = system_prompt_text.encode("utf-8") if system_prompt_text is not None else None
167
+ prompt = None
168
+ if encoded is not None and system_prompt_label is not None:
169
+ import hashlib
170
+
171
+ prompt = StoredSystemPromptConfiguration(
172
+ label=system_prompt_label,
173
+ source_url=system_prompt_source_url,
174
+ chars=len(system_prompt_text),
175
+ bytes=len(encoded),
176
+ sha256=hashlib.sha256(encoded).hexdigest(),
177
+ )
178
+ invocation = AuthorInvocation(
179
+ board_id=board_id,
180
+ author_id=author_id,
181
+ created_at=created_at or datetime.now(UTC),
182
+ provider=provider,
183
+ model_name=model_name,
184
+ normalized_model_name=normalized_model_name,
185
+ display_name=display_name,
186
+ developer=developer,
187
+ generation=generation,
188
+ lineage=lineage,
189
+ reasoning_mode=reasoning_mode,
190
+ reasoning=reasoning,
191
+ openrouter_provider=openrouter_provider,
192
+ bedrock_region=bedrock_region,
193
+ system_prompt=prompt,
194
+ source_run_id=source_run_id,
195
+ repeat_reason=repeat_reason,
196
+ )
197
+ return invocation, encoded
198
+
199
+
200
+ def import_author_from_run(
201
+ *,
202
+ data_repo: Path,
203
+ state_root: Path,
204
+ run_id: str,
205
+ author_id: str,
206
+ replace: bool = False,
207
+ ) -> AuthorInvocation:
208
+ """Retrofit a published author from one retained private run."""
209
+
210
+ public = load_archive(data_repo).authors.get(author_id)
211
+ if public is None or public.kind != "model":
212
+ raise AuthorInvocationError(f"Published model author does not exist: {author_id}")
213
+ run_dir = state_root / run_id
214
+ try:
215
+ manifest = RunManifest.load(run_dir / "manifest.json")
216
+ except (OSError, ValueError) as error:
217
+ raise AuthorInvocationError(f"Cannot load source run {run_id}: {error}") from error
218
+ if (
219
+ public.provider != manifest.identity.provider
220
+ or public.normalized_model_name != manifest.identity.normalized_model_name
221
+ ):
222
+ raise AuthorInvocationError("Source run provider/model identity does not match the published author")
223
+ prompt_text = None
224
+ prompt_label = None
225
+ prompt_source_url = None
226
+ if manifest.system_prompt is not None:
227
+ try:
228
+ prompt_text = (run_dir / manifest.system_prompt.artifact).read_bytes().decode("utf-8")
229
+ except (OSError, UnicodeDecodeError) as error:
230
+ raise AuthorInvocationError(f"Cannot load source run system prompt: {error}") from error
231
+ prompt_label = manifest.system_prompt.label
232
+ prompt_source_url = manifest.system_prompt.source_url
233
+ if public.prompt_configuration is None or (
234
+ public.prompt_configuration.label != prompt_label
235
+ or public.prompt_configuration.source_url != prompt_source_url
236
+ ):
237
+ raise AuthorInvocationError("Source run prompt configuration does not match the published author")
238
+ elif public.prompt_configuration is not None:
239
+ raise AuthorInvocationError("Published author names a prompt configuration absent from the source run")
240
+ reasoning_mode = (
241
+ "mandatory" if manifest.reasoning.mandatory else "enabled" if manifest.reasoning.enabled else "disabled"
242
+ )
243
+ invocation, prompt_bytes = build_author_invocation(
244
+ board_id=manifest.board_id,
245
+ author_id=author_id,
246
+ provider=manifest.identity.provider,
247
+ model_name=manifest.identity.model_name,
248
+ normalized_model_name=manifest.identity.normalized_model_name,
249
+ display_name=public.display_name,
250
+ developer=public.developer,
251
+ generation=public.generation,
252
+ lineage=public.lineage,
253
+ reasoning_mode=reasoning_mode,
254
+ reasoning=manifest.reasoning,
255
+ openrouter_provider=(
256
+ manifest.openrouter_routing.provider_slug if manifest.openrouter_routing is not None else None
257
+ ),
258
+ bedrock_region=(
259
+ manifest.amazon_bedrock_routing.region if manifest.amazon_bedrock_routing is not None else None
260
+ ),
261
+ system_prompt_text=prompt_text,
262
+ system_prompt_label=prompt_label,
263
+ system_prompt_source_url=prompt_source_url,
264
+ source_run_id=run_id,
265
+ repeat_reason=manifest.collision_override_reason,
266
+ created_at=manifest.created_at,
267
+ )
268
+ save_author_invocation(state_root, invocation, system_prompt_bytes=prompt_bytes, replace=replace)
269
+ return invocation