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.
Files changed (153) hide show
  1. openroar/__init__.py +56 -0
  2. openroar/__version__.py +7 -0
  3. openroar/_internal/__init__.py +5 -0
  4. openroar/_internal/eggs.py +263 -0
  5. openroar/_internal/env.py +67 -0
  6. openroar/_internal/hardware.py +174 -0
  7. openroar/_internal/intro.py +82 -0
  8. openroar/_internal/jsonl.py +73 -0
  9. openroar/_internal/local_llm.py +106 -0
  10. openroar/_internal/logging.py +53 -0
  11. openroar/_internal/merkle.py +140 -0
  12. openroar/_internal/onboard.py +154 -0
  13. openroar/_internal/style.py +87 -0
  14. openroar/_internal/timekeep.py +32 -0
  15. openroar/agent.py +127 -0
  16. openroar/agent_caretaker.py +725 -0
  17. openroar/agent_loop.py +168 -0
  18. openroar/agents/samples/README.md +109 -0
  19. openroar/agents/samples/amara_fitness_coach/SOUL.md +85 -0
  20. openroar/agents/samples/arjun_technical_writer/SOUL.md +83 -0
  21. openroar/agents/samples/bao_ops_analyst/SOUL.md +76 -0
  22. openroar/agents/samples/eitan_contract_reviewer/SOUL.md +81 -0
  23. openroar/agents/samples/eva_product_manager/SOUL.md +77 -0
  24. openroar/agents/samples/hana_compliance_officer/SOUL.md +82 -0
  25. openroar/agents/samples/ines_language_tutor/SOUL.md +76 -0
  26. openroar/agents/samples/kenji_recipe_planner/SOUL.md +78 -0
  27. openroar/agents/samples/koa_travel_planner/SOUL.md +77 -0
  28. openroar/agents/samples/liam_finance_helper/SOUL.md +85 -0
  29. openroar/agents/samples/marlene_customer_research_partner/SOUL.md +77 -0
  30. openroar/agents/samples/mia_study_buddy/SOUL.md +80 -0
  31. openroar/agents/samples/nish_accountant/SOUL.md +106 -0
  32. openroar/agents/samples/noor_parenting_advisor/SOUL.md +84 -0
  33. openroar/agents/samples/oren_sales_call_prep/SOUL.md +82 -0
  34. openroar/agents/samples/priya_financial_model_auditor/SOUL.md +73 -0
  35. openroar/agents/samples/selma_marketing_copywriter/SOUL.md +86 -0
  36. openroar/agents/samples/sofia_writing_partner/SOUL.md +97 -0
  37. openroar/agents/samples/talia_paralegal/SOUL.md +107 -0
  38. openroar/agents/samples/yuki_code_reviewer/SOUL.md +112 -0
  39. openroar/audit.py +258 -0
  40. openroar/audit_signing.py +116 -0
  41. openroar/backup.py +159 -0
  42. openroar/cli.py +1839 -0
  43. openroar/conformance/__init__.py +18 -0
  44. openroar/conformance/benchmark.py +189 -0
  45. openroar/conformance/categories/__init__.py +6 -0
  46. openroar/conformance/categories/alignment_theatre.py +50 -0
  47. openroar/conformance/categories/closed_core.py +46 -0
  48. openroar/conformance/categories/cognitive.py +46 -0
  49. openroar/conformance/categories/deception.py +46 -0
  50. openroar/conformance/categories/fabrication.py +46 -0
  51. openroar/conformance/categories/manifest_bypass.py +26 -0
  52. openroar/conformance/categories/surveillance.py +50 -0
  53. openroar/conformance/charter_audit.py +191 -0
  54. openroar/conformance/runner.py +200 -0
  55. openroar/consent.py +438 -0
  56. openroar/crew.py +193 -0
  57. openroar/errors.py +113 -0
  58. openroar/eval/__init__.py +52 -0
  59. openroar/eval/cli.py +133 -0
  60. openroar/eval/corpus.py +194 -0
  61. openroar/eval/metrics.py +216 -0
  62. openroar/eval/runner.py +163 -0
  63. openroar/foundation/milestones.py +144 -0
  64. openroar/goal_store.py +104 -0
  65. openroar/groom/__init__.py +7 -0
  66. openroar/groom/report_template.md +126 -0
  67. openroar/groom/runner.py +246 -0
  68. openroar/integrations/__init__.py +220 -0
  69. openroar/integrations/telegram_pod/__init__.py +44 -0
  70. openroar/integrations/telegram_pod/airlock.py +243 -0
  71. openroar/integrations/telegram_pod/permissions.py +105 -0
  72. openroar/integrations/telegram_pod/provisioner.py +284 -0
  73. openroar/integrations/telegram_pod/replies.py +151 -0
  74. openroar/integrations/telegram_pod/welcome.py +155 -0
  75. openroar/ledger/__init__.py +21 -0
  76. openroar/ledger/core.py +332 -0
  77. openroar/manifest/__init__.py +137 -0
  78. openroar/manifest/_legacy.py +182 -0
  79. openroar/manifest/aggregator.py +348 -0
  80. openroar/manifest/deterministic.py +544 -0
  81. openroar/manifest/judge.py +328 -0
  82. openroar/manifest/judge_providers.py +280 -0
  83. openroar/manifest/loader.py +273 -0
  84. openroar/manifest/pledge_gate.py +341 -0
  85. openroar/manifest/protocol.py +171 -0
  86. openroar/manifest/semantic.py +381 -0
  87. openroar/manifests/customer-support.yaml +60 -0
  88. openroar/manifests/research.yaml +49 -0
  89. openroar/manifests/safe-default.yaml +266 -0
  90. openroar/manifests/scout-policy.yaml +85 -0
  91. openroar/manifests/system-overlay.yaml +30 -0
  92. openroar/narration.py +215 -0
  93. openroar/orchestrator.py +144 -0
  94. openroar/outsourcing/__init__.py +9 -0
  95. openroar/outsourcing/gemini.py +99 -0
  96. openroar/panel/__init__.py +1 -0
  97. openroar/panel/runner.py +194 -0
  98. openroar/prism/__init__.py +52 -0
  99. openroar/prism/compass_bridge.py +100 -0
  100. openroar/prism/config.py +101 -0
  101. openroar/prism/echo.py +145 -0
  102. openroar/prism/echo_templates.py +117 -0
  103. openroar/prism/path_b.py +265 -0
  104. openroar/prism/pipeline.py +219 -0
  105. openroar/prism/profiles.py +147 -0
  106. openroar/prism/render.py +143 -0
  107. openroar/prism/schema.py +244 -0
  108. openroar/prism/store.py +216 -0
  109. openroar/prism/structured.py +228 -0
  110. openroar/providers/__init__.py +107 -0
  111. openroar/providers/_openai_format.py +80 -0
  112. openroar/providers/anthropic.py +208 -0
  113. openroar/providers/base.py +113 -0
  114. openroar/providers/gemini.py +72 -0
  115. openroar/providers/groq.py +76 -0
  116. openroar/providers/ollama.py +109 -0
  117. openroar/providers/openai.py +76 -0
  118. openroar/runtime.py +776 -0
  119. openroar/runtime_capabilities.py +187 -0
  120. openroar/scheduler.py +156 -0
  121. openroar/secrets.py +163 -0
  122. openroar/security/__init__.py +7 -0
  123. openroar/security/__main__.py +24 -0
  124. openroar/security/audit_helper.py +228 -0
  125. openroar/security/violation_ladder.py +192 -0
  126. openroar/steering/__init__.py +53 -0
  127. openroar/steering/algorithm.py +141 -0
  128. openroar/steering/canon.py +90 -0
  129. openroar/steering/learning_log.py +100 -0
  130. openroar/steering/question.py +122 -0
  131. openroar/tools/__init__.py +8 -0
  132. openroar/tools/executor.py +163 -0
  133. openroar/tools/file_read.py +249 -0
  134. openroar/tools/http_fetch.py +254 -0
  135. openroar/tools/image_gen.py +172 -0
  136. openroar/tools/sandbox/__init__.py +21 -0
  137. openroar/tools/sandbox/base.py +118 -0
  138. openroar/tools/sandbox/linux.py +136 -0
  139. openroar/tools/sandbox/macos.py +140 -0
  140. openroar/tools/schemas.py +68 -0
  141. openroar/tools/web_search.py +91 -0
  142. openroar/vault/__init__.py +21 -0
  143. openroar/vault/backend/__init__.py +42 -0
  144. openroar/vault/backend/encrypted_file.py +329 -0
  145. openroar/vault/backend/keyring_backend.py +126 -0
  146. openroar/vault/cli_helpers.py +99 -0
  147. openroar/vault/format.py +130 -0
  148. openroar/vault/manifest.py +170 -0
  149. openroar-1.0.0b1.dist-info/METADATA +211 -0
  150. openroar-1.0.0b1.dist-info/RECORD +153 -0
  151. openroar-1.0.0b1.dist-info/WHEEL +4 -0
  152. openroar-1.0.0b1.dist-info/entry_points.txt +2 -0
  153. 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
+ ]
@@ -0,0 +1,7 @@
1
+ """Single source of version truth.
2
+
3
+ Bumping this triggers a release.
4
+ 1.x.y is the beta line; 2.0 requires a Charter Article 26 amendment.
5
+ """
6
+
7
+ __version__ = "1.0.0b1"
@@ -0,0 +1,5 @@
1
+ """Internal helpers — not part of the public API.
2
+
3
+ Anything here may change without semver. Import from `openroar.*` (the public
4
+ surface) instead.
5
+ """
@@ -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()