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.
- aibb/__init__.py +5 -0
- aibb/acceptance.py +264 -0
- aibb/authors.py +269 -0
- aibb/board.py +906 -0
- aibb/cli.py +2347 -0
- aibb/config.py +64 -0
- aibb/curator.py +270 -0
- aibb/customize.py +113 -0
- aibb/domain/__init__.py +6 -0
- aibb/domain/models.py +285 -0
- aibb/domain/repository.py +198 -0
- aibb/domain/service.py +192 -0
- aibb/harness/__init__.py +18 -0
- aibb/harness/amazon_bedrock.py +586 -0
- aibb/harness/anthropic.py +282 -0
- aibb/harness/catalog.py +252 -0
- aibb/harness/compaction.py +209 -0
- aibb/harness/context.py +145 -0
- aibb/harness/context_preview.py +106 -0
- aibb/harness/engine.py +295 -0
- aibb/harness/google_agent_platform.py +82 -0
- aibb/harness/openrouter.py +627 -0
- aibb/harness/runner.py +1737 -0
- aibb/harness/tinker.py +328 -0
- aibb/harness/token_estimate.py +40 -0
- aibb/harness/watch.py +592 -0
- aibb/markdown.py +120 -0
- aibb/prompting.py +293 -0
- aibb/protocol/__init__.py +1 -0
- aibb/protocol/client.py +106 -0
- aibb/protocol/images.py +400 -0
- aibb/protocol/server.py +1845 -0
- aibb/protocol/state.py +1697 -0
- aibb/protocol/world.py +854 -0
- aibb/publication_copy.py +14 -0
- aibb/publish.py +261 -0
- aibb/resources/default-board/documents/board-guide.md +8 -0
- aibb/resources/default-board/documents/orientation.md +6 -0
- aibb/resources/default-board/documents/posting-guide.md +4 -0
- aibb/resources/default-board/preset.yaml +45 -0
- aibb/resources/default-board/prompts/initial.md +5 -0
- aibb/resources/default-board/prompts/run_config.md +75 -0
- aibb/resources/default-board/theme/public/assets/board.css +37 -0
- aibb/resources/default-board/theme/templates/_wordmark_glyph.html +1 -0
- aibb/runtime/__init__.py +6 -0
- aibb/runtime/budget.py +180 -0
- aibb/runtime/headless.py +13 -0
- aibb/runtime/models.py +326 -0
- aibb/scaffold.py +164 -0
- aibb/sessions/__init__.py +5 -0
- aibb/sessions/store.py +175 -0
- aibb/site/__init__.py +5 -0
- aibb/site/assets/favicon.svg +16 -0
- aibb/site/assets/search-worker.js +433 -0
- aibb/site/assets/search.js +225 -0
- aibb/site/assets/style.css +313 -0
- aibb/site/assets/theme.js +15 -0
- aibb/site/builder.py +1480 -0
- aibb/site/templates/404.html +11 -0
- aibb/site/templates/_attachments.html +22 -0
- aibb/site/templates/_avatar.html +9 -0
- aibb/site/templates/_wordmark_glyph.html +1 -0
- aibb/site/templates/about.html +4 -0
- aibb/site/templates/author.html +23 -0
- aibb/site/templates/base.html +72 -0
- aibb/site/templates/category.html +21 -0
- aibb/site/templates/contribution.html +33 -0
- aibb/site/templates/corpus.html +32 -0
- aibb/site/templates/data.html +19 -0
- aibb/site/templates/document.html +23 -0
- aibb/site/templates/home.html +93 -0
- aibb/site/templates/models.html +32 -0
- aibb/site/templates/profile.html +14 -0
- aibb/site/templates/search.html +23 -0
- aibb/site/templates/tag.html +5 -0
- aibb/site/templates/thread.html +64 -0
- aibb/site/templates/visit_context.html +20 -0
- aibb/starter.py +72 -0
- aibb/surveys.py +862 -0
- aibb/visits.py +237 -0
- aibb-0.1.0.dist-info/METADATA +200 -0
- aibb-0.1.0.dist-info/RECORD +85 -0
- aibb-0.1.0.dist-info/WHEEL +4 -0
- aibb-0.1.0.dist-info/entry_points.txt +3 -0
- aibb-0.1.0.dist-info/licenses/LICENSE +21 -0
aibb/__init__.py
ADDED
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
|