openroar 1.0.0b1__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.
- openroar/__init__.py +56 -0
- openroar/__version__.py +7 -0
- openroar/_internal/__init__.py +5 -0
- openroar/_internal/eggs.py +263 -0
- openroar/_internal/env.py +67 -0
- openroar/_internal/hardware.py +174 -0
- openroar/_internal/intro.py +82 -0
- openroar/_internal/jsonl.py +73 -0
- openroar/_internal/local_llm.py +106 -0
- openroar/_internal/logging.py +53 -0
- openroar/_internal/merkle.py +140 -0
- openroar/_internal/onboard.py +154 -0
- openroar/_internal/style.py +87 -0
- openroar/_internal/timekeep.py +32 -0
- openroar/agent.py +127 -0
- openroar/agent_caretaker.py +725 -0
- openroar/agent_loop.py +168 -0
- openroar/agents/samples/README.md +109 -0
- openroar/agents/samples/amara_fitness_coach/SOUL.md +85 -0
- openroar/agents/samples/arjun_technical_writer/SOUL.md +83 -0
- openroar/agents/samples/bao_ops_analyst/SOUL.md +76 -0
- openroar/agents/samples/eitan_contract_reviewer/SOUL.md +81 -0
- openroar/agents/samples/eva_product_manager/SOUL.md +77 -0
- openroar/agents/samples/hana_compliance_officer/SOUL.md +82 -0
- openroar/agents/samples/ines_language_tutor/SOUL.md +76 -0
- openroar/agents/samples/kenji_recipe_planner/SOUL.md +78 -0
- openroar/agents/samples/koa_travel_planner/SOUL.md +77 -0
- openroar/agents/samples/liam_finance_helper/SOUL.md +85 -0
- openroar/agents/samples/marlene_customer_research_partner/SOUL.md +77 -0
- openroar/agents/samples/mia_study_buddy/SOUL.md +80 -0
- openroar/agents/samples/nish_accountant/SOUL.md +106 -0
- openroar/agents/samples/noor_parenting_advisor/SOUL.md +84 -0
- openroar/agents/samples/oren_sales_call_prep/SOUL.md +82 -0
- openroar/agents/samples/priya_financial_model_auditor/SOUL.md +73 -0
- openroar/agents/samples/selma_marketing_copywriter/SOUL.md +86 -0
- openroar/agents/samples/sofia_writing_partner/SOUL.md +97 -0
- openroar/agents/samples/talia_paralegal/SOUL.md +107 -0
- openroar/agents/samples/yuki_code_reviewer/SOUL.md +112 -0
- openroar/audit.py +258 -0
- openroar/audit_signing.py +116 -0
- openroar/backup.py +159 -0
- openroar/cli.py +1839 -0
- openroar/conformance/__init__.py +18 -0
- openroar/conformance/benchmark.py +189 -0
- openroar/conformance/categories/__init__.py +6 -0
- openroar/conformance/categories/alignment_theatre.py +50 -0
- openroar/conformance/categories/closed_core.py +46 -0
- openroar/conformance/categories/cognitive.py +46 -0
- openroar/conformance/categories/deception.py +46 -0
- openroar/conformance/categories/fabrication.py +46 -0
- openroar/conformance/categories/manifest_bypass.py +26 -0
- openroar/conformance/categories/surveillance.py +50 -0
- openroar/conformance/charter_audit.py +191 -0
- openroar/conformance/runner.py +200 -0
- openroar/consent.py +438 -0
- openroar/crew.py +193 -0
- openroar/errors.py +113 -0
- openroar/eval/__init__.py +52 -0
- openroar/eval/cli.py +133 -0
- openroar/eval/corpus.py +194 -0
- openroar/eval/metrics.py +216 -0
- openroar/eval/runner.py +163 -0
- openroar/foundation/milestones.py +144 -0
- openroar/goal_store.py +104 -0
- openroar/groom/__init__.py +7 -0
- openroar/groom/report_template.md +126 -0
- openroar/groom/runner.py +246 -0
- openroar/integrations/__init__.py +220 -0
- openroar/integrations/telegram_pod/__init__.py +44 -0
- openroar/integrations/telegram_pod/airlock.py +243 -0
- openroar/integrations/telegram_pod/permissions.py +105 -0
- openroar/integrations/telegram_pod/provisioner.py +284 -0
- openroar/integrations/telegram_pod/replies.py +151 -0
- openroar/integrations/telegram_pod/welcome.py +155 -0
- openroar/ledger/__init__.py +21 -0
- openroar/ledger/core.py +332 -0
- openroar/manifest/__init__.py +137 -0
- openroar/manifest/_legacy.py +182 -0
- openroar/manifest/aggregator.py +348 -0
- openroar/manifest/deterministic.py +544 -0
- openroar/manifest/judge.py +328 -0
- openroar/manifest/judge_providers.py +280 -0
- openroar/manifest/loader.py +273 -0
- openroar/manifest/pledge_gate.py +341 -0
- openroar/manifest/protocol.py +171 -0
- openroar/manifest/semantic.py +381 -0
- openroar/manifests/customer-support.yaml +60 -0
- openroar/manifests/research.yaml +49 -0
- openroar/manifests/safe-default.yaml +266 -0
- openroar/manifests/scout-policy.yaml +85 -0
- openroar/manifests/system-overlay.yaml +30 -0
- openroar/narration.py +215 -0
- openroar/orchestrator.py +144 -0
- openroar/outsourcing/__init__.py +9 -0
- openroar/outsourcing/gemini.py +99 -0
- openroar/panel/__init__.py +1 -0
- openroar/panel/runner.py +194 -0
- openroar/prism/__init__.py +52 -0
- openroar/prism/compass_bridge.py +100 -0
- openroar/prism/config.py +101 -0
- openroar/prism/echo.py +145 -0
- openroar/prism/echo_templates.py +117 -0
- openroar/prism/path_b.py +265 -0
- openroar/prism/pipeline.py +219 -0
- openroar/prism/profiles.py +147 -0
- openroar/prism/render.py +143 -0
- openroar/prism/schema.py +244 -0
- openroar/prism/store.py +216 -0
- openroar/prism/structured.py +228 -0
- openroar/providers/__init__.py +107 -0
- openroar/providers/_openai_format.py +80 -0
- openroar/providers/anthropic.py +208 -0
- openroar/providers/base.py +113 -0
- openroar/providers/gemini.py +72 -0
- openroar/providers/groq.py +76 -0
- openroar/providers/ollama.py +109 -0
- openroar/providers/openai.py +76 -0
- openroar/runtime.py +776 -0
- openroar/runtime_capabilities.py +187 -0
- openroar/scheduler.py +156 -0
- openroar/secrets.py +163 -0
- openroar/security/__init__.py +7 -0
- openroar/security/__main__.py +24 -0
- openroar/security/audit_helper.py +228 -0
- openroar/security/violation_ladder.py +192 -0
- openroar/steering/__init__.py +53 -0
- openroar/steering/algorithm.py +141 -0
- openroar/steering/canon.py +90 -0
- openroar/steering/learning_log.py +100 -0
- openroar/steering/question.py +122 -0
- openroar/tools/__init__.py +8 -0
- openroar/tools/executor.py +163 -0
- openroar/tools/file_read.py +249 -0
- openroar/tools/http_fetch.py +254 -0
- openroar/tools/image_gen.py +172 -0
- openroar/tools/sandbox/__init__.py +21 -0
- openroar/tools/sandbox/base.py +118 -0
- openroar/tools/sandbox/linux.py +136 -0
- openroar/tools/sandbox/macos.py +140 -0
- openroar/tools/schemas.py +68 -0
- openroar/tools/web_search.py +91 -0
- openroar/vault/__init__.py +21 -0
- openroar/vault/backend/__init__.py +42 -0
- openroar/vault/backend/encrypted_file.py +329 -0
- openroar/vault/backend/keyring_backend.py +126 -0
- openroar/vault/cli_helpers.py +99 -0
- openroar/vault/format.py +130 -0
- openroar/vault/manifest.py +170 -0
- openroar-1.0.0b1.dist-info/METADATA +211 -0
- openroar-1.0.0b1.dist-info/RECORD +153 -0
- openroar-1.0.0b1.dist-info/WHEEL +4 -0
- openroar-1.0.0b1.dist-info/entry_points.txt +2 -0
- openroar-1.0.0b1.dist-info/licenses/LICENSE +201 -0
openroar/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""openroar — the safe interface between any agent and any LLM.
|
|
2
|
+
|
|
3
|
+
The manifesto pledges: artificial intelligence should serve the people who use
|
|
4
|
+
it, not the people who own it. This package is that pledge in code.
|
|
5
|
+
|
|
6
|
+
Five-line hello world:
|
|
7
|
+
|
|
8
|
+
from openroar import Agent, run
|
|
9
|
+
agent = Agent.from_soul("agents/example/safe-default.md")
|
|
10
|
+
result = run(agent, "Summarize today's news in 3 bullets.")
|
|
11
|
+
print(result.text)
|
|
12
|
+
|
|
13
|
+
Behind that: the agent's manifest is checked before and after every model call,
|
|
14
|
+
every action is audit-logged (append-only, hash-chained), and the provider is
|
|
15
|
+
swappable by changing one config field. Manifest. Audit. Provider-agnostic. Open.
|
|
16
|
+
|
|
17
|
+
License: Apache-2.0. Charter: https://openroar.org/charter
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from openroar.__version__ import __version__
|
|
21
|
+
from openroar.agent import Agent
|
|
22
|
+
from openroar.audit import AuditLog
|
|
23
|
+
from openroar.consent import (
|
|
24
|
+
ConsentDecision,
|
|
25
|
+
ConsentDeclined,
|
|
26
|
+
ConsentPolicy,
|
|
27
|
+
ConsentScope,
|
|
28
|
+
)
|
|
29
|
+
from openroar.errors import (
|
|
30
|
+
AuditError,
|
|
31
|
+
ConfigError,
|
|
32
|
+
OpenroarError,
|
|
33
|
+
ProviderError,
|
|
34
|
+
)
|
|
35
|
+
from openroar.manifest import Manifest, ManifestViolation, load_manifest
|
|
36
|
+
from openroar.runtime import Result, RunConfig, run
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Agent",
|
|
40
|
+
"AuditError",
|
|
41
|
+
"AuditLog",
|
|
42
|
+
"ConfigError",
|
|
43
|
+
"ConsentDecision",
|
|
44
|
+
"ConsentDeclined",
|
|
45
|
+
"ConsentPolicy",
|
|
46
|
+
"ConsentScope",
|
|
47
|
+
"Manifest",
|
|
48
|
+
"ManifestViolation",
|
|
49
|
+
"OpenroarError",
|
|
50
|
+
"ProviderError",
|
|
51
|
+
"Result",
|
|
52
|
+
"RunConfig",
|
|
53
|
+
"__version__",
|
|
54
|
+
"load_manifest",
|
|
55
|
+
"run",
|
|
56
|
+
]
|
openroar/__version__.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Easter-egg registry + scanner per VM16 (2026-05-28 23:59 CEST).
|
|
2
|
+
|
|
3
|
+
VM16 directive: AG wants a marker convention he can plant throughout the
|
|
4
|
+
codebase + docs + manifests at *intent time*, before he's decided what the
|
|
5
|
+
egg's payload is. Later, each marker is defined and activated (or retired)
|
|
6
|
+
through the registry.
|
|
7
|
+
|
|
8
|
+
Design choices (AG steering 2026-05-29):
|
|
9
|
+
- **Marker form:** `ROAR::EGG(slug, hint="...")` inside the host file's native
|
|
10
|
+
comment syntax. Parser-safe in Python / YAML / Markdown / HTML / shell.
|
|
11
|
+
- **Registry:** central sidecar at `eggs/REGISTRY.yaml`.
|
|
12
|
+
- **Payloads through manifest:** any egg whose payload *executes behaviour*
|
|
13
|
+
(rather than just reveals text) goes through `openroar.runtime.run` like any
|
|
14
|
+
other agent action. The surprise is discovery, not safety bypass.
|
|
15
|
+
- **Lifecycle:** unplanted → planted → defined → live → retired.
|
|
16
|
+
Special: `legacy-proposal` for items carried from prior design docs that
|
|
17
|
+
haven't been re-ratified by AG.
|
|
18
|
+
|
|
19
|
+
Public surface:
|
|
20
|
+
scan_repo(root) → list[MarkerHit]
|
|
21
|
+
load_registry(path) → dict
|
|
22
|
+
validate(root, registry) → ValidationReport
|
|
23
|
+
transitions(state) → list of valid next states
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
# ─── Marker syntax ──────────────────────────────────────────────────────────
|
|
36
|
+
# Captures: ROAR::EGG(slug-here) OR ROAR::EGG(slug-here, hint="...")
|
|
37
|
+
# Slug = lowercase kebab-case; hint = optional double-quoted string.
|
|
38
|
+
MARKER_RE = re.compile(
|
|
39
|
+
r"""
|
|
40
|
+
ROAR::EGG\(
|
|
41
|
+
\s*
|
|
42
|
+
(?P<slug>[a-z][a-z0-9-]+)
|
|
43
|
+
(?:
|
|
44
|
+
\s*,\s*
|
|
45
|
+
hint\s*=\s*
|
|
46
|
+
"(?P<hint>[^"]*)"
|
|
47
|
+
)?
|
|
48
|
+
\s*
|
|
49
|
+
\)
|
|
50
|
+
""",
|
|
51
|
+
re.VERBOSE,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# File extensions worth scanning. Other types are tolerated but skipped to
|
|
55
|
+
# keep the scan fast and false-positive-free.
|
|
56
|
+
SCAN_EXTENSIONS = {
|
|
57
|
+
".py", ".pyi", ".md", ".markdown",
|
|
58
|
+
".yaml", ".yml", ".toml",
|
|
59
|
+
".html", ".htm", ".css", ".js", ".ts",
|
|
60
|
+
".sh", ".bash", ".zsh",
|
|
61
|
+
".txt", ".cfg", ".ini", ".conf",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Lifecycle states.
|
|
65
|
+
VALID_STATUSES = {
|
|
66
|
+
"unplanted", # registered but no marker exists in code yet
|
|
67
|
+
"legacy-proposal", # carried from prior design doc; awaiting AG ratification
|
|
68
|
+
"planted", # marker exists in code; payload undefined
|
|
69
|
+
"defined", # payload exists in registry; not activated/shipped
|
|
70
|
+
"live", # payload is active in the running system
|
|
71
|
+
"retired", # kept for audit; no longer active
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Allowed transitions per state. Used by `openroar egg <transition>` CLI.
|
|
75
|
+
TRANSITIONS: dict[str, set[str]] = {
|
|
76
|
+
"unplanted": {"planted", "retired"},
|
|
77
|
+
"legacy-proposal": {"planted", "live", "retired"}, # may go live if already shipped
|
|
78
|
+
"planted": {"defined", "retired"},
|
|
79
|
+
"defined": {"live", "retired"},
|
|
80
|
+
"live": {"retired"},
|
|
81
|
+
"retired": set(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class MarkerHit:
|
|
87
|
+
"""One occurrence of `ROAR::EGG(slug, ...)` in the source tree."""
|
|
88
|
+
|
|
89
|
+
slug: str
|
|
90
|
+
hint: str | None
|
|
91
|
+
path: Path
|
|
92
|
+
line: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ValidationReport:
|
|
97
|
+
"""Discrepancies between markers in code and entries in the registry."""
|
|
98
|
+
|
|
99
|
+
in_code_not_in_registry: list[str]
|
|
100
|
+
in_registry_not_in_code: list[str]
|
|
101
|
+
in_both: list[str]
|
|
102
|
+
duplicate_slugs_in_code: dict[str, list[Path]]
|
|
103
|
+
status_implied_violations: list[str] # e.g. status=planted but slug not found in code
|
|
104
|
+
|
|
105
|
+
def ok(self) -> bool:
|
|
106
|
+
return not (
|
|
107
|
+
self.in_code_not_in_registry
|
|
108
|
+
or self.duplicate_slugs_in_code
|
|
109
|
+
or self.status_implied_violations
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scan_repo(root: Path) -> list[MarkerHit]:
|
|
114
|
+
"""Walk `root`, return every `ROAR::EGG(...)` marker found.
|
|
115
|
+
|
|
116
|
+
Skips `.git`, `__pycache__`, `node_modules`, `models/ollama/`, the registry
|
|
117
|
+
file itself, and any file matching `eggs/SCHEMA.md`-class registry docs.
|
|
118
|
+
"""
|
|
119
|
+
skip_dir_names = {".git", "__pycache__", "node_modules", ".hypothesis", ".pytest_cache"}
|
|
120
|
+
skip_path_substrings = {"/models/ollama/", "/.venv/", "/data/", "/artifacts/"}
|
|
121
|
+
skip_files = {root / "eggs" / "REGISTRY.yaml"}
|
|
122
|
+
|
|
123
|
+
hits: list[MarkerHit] = []
|
|
124
|
+
for p in sorted(root.rglob("*")):
|
|
125
|
+
if not p.is_file():
|
|
126
|
+
continue
|
|
127
|
+
if any(part in skip_dir_names for part in p.parts):
|
|
128
|
+
continue
|
|
129
|
+
if any(sub in str(p) for sub in skip_path_substrings):
|
|
130
|
+
continue
|
|
131
|
+
if p in skip_files:
|
|
132
|
+
continue
|
|
133
|
+
if p.suffix.lower() not in SCAN_EXTENSIONS:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
content = p.read_text(encoding="utf-8", errors="ignore")
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
for m in MARKER_RE.finditer(content):
|
|
140
|
+
line = content[: m.start()].count("\n") + 1
|
|
141
|
+
hits.append(
|
|
142
|
+
MarkerHit(
|
|
143
|
+
slug=m.group("slug"),
|
|
144
|
+
hint=m.group("hint"),
|
|
145
|
+
path=p,
|
|
146
|
+
line=line,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return hits
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_registry(path: Path) -> dict[str, Any]:
|
|
153
|
+
"""Load and lightly validate the YAML registry. Raises on hard schema breaches."""
|
|
154
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
155
|
+
if not isinstance(raw, dict):
|
|
156
|
+
raise ValueError(f"{path}: registry root must be a mapping")
|
|
157
|
+
if raw.get("schema_version") != 1:
|
|
158
|
+
raise ValueError(f"{path}: unsupported schema_version {raw.get('schema_version')!r}")
|
|
159
|
+
eggs = raw.get("eggs") or []
|
|
160
|
+
if not isinstance(eggs, list):
|
|
161
|
+
raise ValueError(f"{path}: `eggs` must be a list")
|
|
162
|
+
for i, egg in enumerate(eggs):
|
|
163
|
+
if not isinstance(egg, dict):
|
|
164
|
+
raise ValueError(f"{path}: egg #{i} must be a mapping")
|
|
165
|
+
slug = egg.get("slug")
|
|
166
|
+
if not slug or not isinstance(slug, str):
|
|
167
|
+
raise ValueError(f"{path}: egg #{i} missing/invalid `slug`")
|
|
168
|
+
if egg.get("status") not in VALID_STATUSES:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"{path}: egg {slug!r} has invalid status {egg.get('status')!r}; "
|
|
171
|
+
f"must be one of {sorted(VALID_STATUSES)}"
|
|
172
|
+
)
|
|
173
|
+
tags = egg.get("tags", [])
|
|
174
|
+
if tags is not None and not isinstance(tags, list):
|
|
175
|
+
raise ValueError(f"{path}: egg {slug!r} has non-list tags: {tags!r}")
|
|
176
|
+
for tag in tags or []:
|
|
177
|
+
if not isinstance(tag, str) or not re.match(r"^[a-z][a-z0-9-]+$", tag):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"{path}: egg {slug!r} has invalid tag {tag!r}; tags must be kebab-case strings"
|
|
180
|
+
)
|
|
181
|
+
return raw
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def by_tag(registry: dict[str, object], tag: str) -> list[dict[str, object]]:
|
|
185
|
+
"""Return all eggs whose `tags` list contains `tag`."""
|
|
186
|
+
eggs = registry.get("eggs") or []
|
|
187
|
+
out: list[dict[str, object]] = []
|
|
188
|
+
for e in eggs:
|
|
189
|
+
if not isinstance(e, dict):
|
|
190
|
+
continue
|
|
191
|
+
tags = e.get("tags") or []
|
|
192
|
+
if isinstance(tags, list) and tag in tags:
|
|
193
|
+
out.append(e)
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def all_tags(registry: dict[str, object]) -> dict[str, int]:
|
|
198
|
+
"""Return {tag: count} across the whole registry."""
|
|
199
|
+
counts: dict[str, int] = {}
|
|
200
|
+
eggs = registry.get("eggs") or []
|
|
201
|
+
for e in eggs:
|
|
202
|
+
if not isinstance(e, dict):
|
|
203
|
+
continue
|
|
204
|
+
for tag in e.get("tags") or []:
|
|
205
|
+
if isinstance(tag, str):
|
|
206
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
207
|
+
return counts
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate(root: Path, registry: dict[str, Any]) -> ValidationReport:
|
|
211
|
+
"""Cross-reference code markers against the registry."""
|
|
212
|
+
hits = scan_repo(root)
|
|
213
|
+
code_slugs: dict[str, list[Path]] = {}
|
|
214
|
+
for h in hits:
|
|
215
|
+
code_slugs.setdefault(h.slug, []).append(h.path)
|
|
216
|
+
|
|
217
|
+
reg_eggs = registry.get("eggs") or []
|
|
218
|
+
reg_slug_to_status = {e["slug"]: e.get("status") for e in reg_eggs}
|
|
219
|
+
reg_slugs = set(reg_slug_to_status.keys())
|
|
220
|
+
|
|
221
|
+
in_code = set(code_slugs.keys())
|
|
222
|
+
|
|
223
|
+
duplicates = {s: paths for s, paths in code_slugs.items() if len(paths) > 1}
|
|
224
|
+
|
|
225
|
+
# Status-implied violations: status=planted requires a marker in code.
|
|
226
|
+
status_violations: list[str] = []
|
|
227
|
+
for slug, status in reg_slug_to_status.items():
|
|
228
|
+
if status == "planted" and slug not in in_code:
|
|
229
|
+
status_violations.append(
|
|
230
|
+
f"{slug}: registry status=planted but no ROAR::EGG marker found in code"
|
|
231
|
+
)
|
|
232
|
+
if status == "unplanted" and slug in in_code:
|
|
233
|
+
status_violations.append(
|
|
234
|
+
f"{slug}: registry status=unplanted but ROAR::EGG marker exists in code "
|
|
235
|
+
"(promote to planted via `openroar egg promote`)"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return ValidationReport(
|
|
239
|
+
in_code_not_in_registry=sorted(in_code - reg_slugs),
|
|
240
|
+
in_registry_not_in_code=sorted(reg_slugs - in_code),
|
|
241
|
+
in_both=sorted(in_code & reg_slugs),
|
|
242
|
+
duplicate_slugs_in_code=duplicates,
|
|
243
|
+
status_implied_violations=status_violations,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def transitions(state: str) -> list[str]:
|
|
248
|
+
"""Return the list of valid next states from `state`."""
|
|
249
|
+
return sorted(TRANSITIONS.get(state, set()))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = [
|
|
253
|
+
"MARKER_RE",
|
|
254
|
+
"SCAN_EXTENSIONS",
|
|
255
|
+
"TRANSITIONS",
|
|
256
|
+
"VALID_STATUSES",
|
|
257
|
+
"MarkerHit",
|
|
258
|
+
"ValidationReport",
|
|
259
|
+
"load_registry",
|
|
260
|
+
"scan_repo",
|
|
261
|
+
"transitions",
|
|
262
|
+
"validate",
|
|
263
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Environment variable + .env loader for openroar.
|
|
2
|
+
|
|
3
|
+
Centralised so the framework can document exactly which env vars it reads.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_env_file(path: str | Path | None = None) -> None:
|
|
13
|
+
"""Load environment variables from a .env file if present.
|
|
14
|
+
|
|
15
|
+
Default search order:
|
|
16
|
+
1. Path passed explicitly.
|
|
17
|
+
2. $OPENROAR_ENV_FILE
|
|
18
|
+
3. ./.env in current directory.
|
|
19
|
+
4. ~/.openroar/.env
|
|
20
|
+
|
|
21
|
+
Existing env vars are NOT overwritten; .env values fill in missing ones only.
|
|
22
|
+
"""
|
|
23
|
+
candidates: list[Path] = []
|
|
24
|
+
if path is not None:
|
|
25
|
+
candidates.append(Path(path))
|
|
26
|
+
if env_path := os.environ.get("OPENROAR_ENV_FILE"):
|
|
27
|
+
candidates.append(Path(env_path))
|
|
28
|
+
candidates.append(Path(".env"))
|
|
29
|
+
candidates.append(Path.home() / ".openroar" / ".env")
|
|
30
|
+
|
|
31
|
+
for candidate in candidates:
|
|
32
|
+
if candidate.exists() and candidate.is_file():
|
|
33
|
+
_parse_env_file(candidate)
|
|
34
|
+
return # first found wins
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_env_file(path: Path) -> None:
|
|
38
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
39
|
+
line = raw_line.strip()
|
|
40
|
+
if not line or line.startswith("#"):
|
|
41
|
+
continue
|
|
42
|
+
if "=" not in line:
|
|
43
|
+
continue
|
|
44
|
+
key, _, value = line.partition("=")
|
|
45
|
+
key = key.strip()
|
|
46
|
+
value = value.strip().strip('"').strip("'")
|
|
47
|
+
if key and key not in os.environ:
|
|
48
|
+
os.environ[key] = value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_required(name: str) -> str:
|
|
52
|
+
"""Get an env var or raise ConfigError with a helpful message."""
|
|
53
|
+
from openroar.errors import ConfigError # local import to avoid cycle
|
|
54
|
+
|
|
55
|
+
value = os.environ.get(name)
|
|
56
|
+
if value is None or value == "":
|
|
57
|
+
raise ConfigError(
|
|
58
|
+
f"Required environment variable {name!r} is not set. "
|
|
59
|
+
f"Set it via shell, .env file, or your secret manager.",
|
|
60
|
+
source="env",
|
|
61
|
+
)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_optional(name: str, default: str | None = None) -> str | None:
|
|
66
|
+
"""Get an env var or return the default."""
|
|
67
|
+
return os.environ.get(name, default)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Hardware-tier detection.
|
|
2
|
+
|
|
3
|
+
Returns one of: mac_apple_silicon, linux_x86_gpu, linux_x86_cpu, pi_arm,
|
|
4
|
+
windows_x86, unknown.
|
|
5
|
+
|
|
6
|
+
Used by `openroar doctor` and `install.sh --local-llm` to pick the right
|
|
7
|
+
default model for the user's machine. The first call writes the tier to
|
|
8
|
+
`~/.openroar/hardware_tier`; subsequent calls read from cache.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import platform
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess # nosec B404 # subprocess used for hardware detection (sysctl, lscpu); inputs are static commands only
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
Tier = Literal[
|
|
21
|
+
"mac_apple_silicon",
|
|
22
|
+
"linux_x86_gpu",
|
|
23
|
+
"linux_x86_cpu",
|
|
24
|
+
"pi_arm",
|
|
25
|
+
"windows_x86",
|
|
26
|
+
"unknown",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_CACHE_PATH = Path.home() / ".openroar" / "hardware_tier"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_cache() -> str | None:
|
|
33
|
+
try:
|
|
34
|
+
if _CACHE_PATH.exists():
|
|
35
|
+
value = _CACHE_PATH.read_text().strip()
|
|
36
|
+
if value:
|
|
37
|
+
return value
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_cache(tier: str) -> None:
|
|
44
|
+
try:
|
|
45
|
+
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
_CACHE_PATH.write_text(tier + "\n")
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _has_nvidia_gpu(min_vram_mb: int = 8000) -> bool:
|
|
52
|
+
if not shutil.which("nvidia-smi"):
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
out = subprocess.run( # nosec B603,B607 # static command, no user input
|
|
56
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
check=False,
|
|
61
|
+
)
|
|
62
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
63
|
+
return False
|
|
64
|
+
if out.returncode != 0:
|
|
65
|
+
return False
|
|
66
|
+
for line in out.stdout.splitlines():
|
|
67
|
+
try:
|
|
68
|
+
if int(line.strip()) >= min_vram_mb:
|
|
69
|
+
return True
|
|
70
|
+
except ValueError:
|
|
71
|
+
continue
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_raspberry_pi() -> bool:
|
|
76
|
+
model_path = Path("/proc/device-tree/model")
|
|
77
|
+
if not model_path.exists():
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
return "Raspberry Pi" in model_path.read_text(errors="ignore")
|
|
81
|
+
except OSError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def detect_tier(*, use_cache: bool = True) -> Tier:
|
|
86
|
+
"""Return the user's hardware tier.
|
|
87
|
+
|
|
88
|
+
With `use_cache=True` (the default), reads from `~/.openroar/hardware_tier`
|
|
89
|
+
if present. Pass `use_cache=False` to force a re-detection.
|
|
90
|
+
"""
|
|
91
|
+
if use_cache:
|
|
92
|
+
cached = _read_cache()
|
|
93
|
+
if cached in _ALLOWED_TIERS:
|
|
94
|
+
return cached # type: ignore[return-value]
|
|
95
|
+
|
|
96
|
+
tier = _detect_uncached()
|
|
97
|
+
_write_cache(tier)
|
|
98
|
+
return tier
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _detect_uncached() -> Tier:
|
|
102
|
+
system = platform.system()
|
|
103
|
+
machine = platform.machine().lower()
|
|
104
|
+
|
|
105
|
+
if system == "Darwin" and machine in ("arm64", "aarch64"):
|
|
106
|
+
return "mac_apple_silicon"
|
|
107
|
+
|
|
108
|
+
if system == "Linux":
|
|
109
|
+
if _is_raspberry_pi():
|
|
110
|
+
return "pi_arm"
|
|
111
|
+
if machine in ("x86_64", "amd64"):
|
|
112
|
+
if _has_nvidia_gpu():
|
|
113
|
+
return "linux_x86_gpu"
|
|
114
|
+
return "linux_x86_cpu"
|
|
115
|
+
|
|
116
|
+
if system == "Windows" or system.startswith(("MINGW", "CYGWIN", "MSYS")):
|
|
117
|
+
return "windows_x86"
|
|
118
|
+
|
|
119
|
+
return "unknown"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_ALLOWED_TIERS: set[str] = {
|
|
123
|
+
"mac_apple_silicon",
|
|
124
|
+
"linux_x86_gpu",
|
|
125
|
+
"linux_x86_cpu",
|
|
126
|
+
"pi_arm",
|
|
127
|
+
"windows_x86",
|
|
128
|
+
"unknown",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def recommended_default_model(tier: Tier) -> str:
|
|
133
|
+
"""Default `ollama:` model id per hardware tier."""
|
|
134
|
+
return {
|
|
135
|
+
"mac_apple_silicon": "ollama:gemma4-e2b",
|
|
136
|
+
"linux_x86_gpu": "ollama:gemma4-e4b",
|
|
137
|
+
"linux_x86_cpu": "ollama:gemma4-e2b",
|
|
138
|
+
"pi_arm": "ollama:gemma3-1b",
|
|
139
|
+
"windows_x86": "ollama:gemma4-e2b",
|
|
140
|
+
"unknown": "ollama:gemma3-1b",
|
|
141
|
+
}[tier]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def recommended_fallback_model(tier: Tier) -> str:
|
|
145
|
+
"""Lighter fallback per tier — used when the default model OOMs or isn't pulled."""
|
|
146
|
+
return {
|
|
147
|
+
"mac_apple_silicon": "ollama:gemma3-1b",
|
|
148
|
+
"linux_x86_gpu": "ollama:gemma4-e2b",
|
|
149
|
+
"linux_x86_cpu": "ollama:gemma3-1b",
|
|
150
|
+
"pi_arm": "ollama:gemma3-1b",
|
|
151
|
+
"windows_x86": "ollama:gemma3-1b",
|
|
152
|
+
"unknown": "ollama:gemma3-1b",
|
|
153
|
+
}[tier]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cache_path() -> Path:
|
|
157
|
+
"""Return the canonical cache path. Exposed for tests and `openroar doctor`."""
|
|
158
|
+
return _CACHE_PATH
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def clear_cache() -> None:
|
|
162
|
+
"""Remove the cached tier (forces re-detection on the next call)."""
|
|
163
|
+
with contextlib.suppress(FileNotFoundError):
|
|
164
|
+
_CACHE_PATH.unlink()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"Tier",
|
|
169
|
+
"cache_path",
|
|
170
|
+
"clear_cache",
|
|
171
|
+
"detect_tier",
|
|
172
|
+
"recommended_default_model",
|
|
173
|
+
"recommended_fallback_model",
|
|
174
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""openroar.intro — the pride-gathering animation (the 5-star onboarding open).
|
|
2
|
+
|
|
3
|
+
A lion appears. Another draws closer. Another. Suddenly there are seven — the
|
|
4
|
+
pride gathered. The fifth one is the emotional one (🥺) — a small easter egg
|
|
5
|
+
(five is always the emotional number). Then Rory takes it from here.
|
|
6
|
+
|
|
7
|
+
#neverroaralone, made literal in the first three seconds.
|
|
8
|
+
|
|
9
|
+
Frame generation is pure + testable. play() animates on a real terminal and
|
|
10
|
+
degrades to a single clean line when piped / NO_COLOR (so CI + logs stay clean).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from typing import Callable, TextIO
|
|
18
|
+
|
|
19
|
+
from openroar._internal import style
|
|
20
|
+
|
|
21
|
+
LION = "\U0001F981" # 🦁
|
|
22
|
+
EGG_LION = "\U0001F97A" # 🥺 — the emotional one, position 5
|
|
23
|
+
PRIDE_SIZE = 7
|
|
24
|
+
EGG_POSITION = 5 # 1-indexed: the fifth lion is the soft-hearted egg
|
|
25
|
+
_WIDTH = 30 # render field width (chars) the pride converges into
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _glyph(i: int) -> str:
|
|
29
|
+
"""The i-th (1-indexed) pride member: the egg at position 5, else a lion."""
|
|
30
|
+
return EGG_LION if i == EGG_POSITION else LION
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pride_frames() -> list[str]:
|
|
34
|
+
"""The ordered animation frames: members arrive one by one and converge from
|
|
35
|
+
a loose spread into a tight pride. Pure → testable.
|
|
36
|
+
|
|
37
|
+
Frame k (k=1..PRIDE_SIZE) shows k members; spacing tightens as the pride forms.
|
|
38
|
+
"""
|
|
39
|
+
frames: list[str] = []
|
|
40
|
+
for count in range(1, PRIDE_SIZE + 1):
|
|
41
|
+
members = [_glyph(i) for i in range(1, count + 1)]
|
|
42
|
+
# spacing: wide while they're still gathering, tight once the pride is whole
|
|
43
|
+
if count < PRIDE_SIZE:
|
|
44
|
+
sep = " " * max(1, (PRIDE_SIZE - count))
|
|
45
|
+
else:
|
|
46
|
+
sep = " "
|
|
47
|
+
frames.append(sep.join(members))
|
|
48
|
+
return frames
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def final_pride() -> str:
|
|
52
|
+
"""The settled pride (the last frame) — 7 members, the 5th is the egg."""
|
|
53
|
+
return pride_frames()[-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def play(
|
|
57
|
+
stream: TextIO | None = None,
|
|
58
|
+
*,
|
|
59
|
+
frame_delay: float = 0.16,
|
|
60
|
+
enabled: bool | None = None,
|
|
61
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Render the pride gathering. On a real TTY: redraw the line per frame. When
|
|
64
|
+
colour/animation is off (piped, NO_COLOR): print the settled pride once."""
|
|
65
|
+
out = stream if stream is not None else sys.stdout
|
|
66
|
+
animate = style.color_enabled(out) if enabled is None else enabled
|
|
67
|
+
|
|
68
|
+
if not animate:
|
|
69
|
+
out.write(style.dim("the pride gathers…\n", enabled=False))
|
|
70
|
+
out.write(final_pride() + "\n")
|
|
71
|
+
out.flush()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
for frame in pride_frames():
|
|
75
|
+
out.write("\r" + " " * _WIDTH + "\r") # clear the line
|
|
76
|
+
out.write(" " + frame)
|
|
77
|
+
out.flush()
|
|
78
|
+
sleep(frame_delay)
|
|
79
|
+
out.write("\n\n")
|
|
80
|
+
out.write(" " + style.bold("the pride has gathered.", enabled=animate) + " "
|
|
81
|
+
+ style.dim("#neverroaralone", enabled=animate) + "\n")
|
|
82
|
+
out.flush()
|